Phase 12: remove phone-relay dashcam upload feature
Removes the phone-relay feature entirely per the Phase 11 finding that it hit a fundamental phone-OS limitation (a browser page can't route WiFi and cellular traffic simultaneously, no code fix possible) - see PLAN.md for the full test writeup. - app.py: dropped /api/dash/pending, /api/dash/file/<path>, /api/dash/relay/config, /api/dash/relay/heartbeat, /api/dash/relay/stop, /api/dash/relay/mark_uploaded routes and their now-unused imports (os, send_file, abort, safe_join). - modules/app_upload.py: dropped list_pending_dashcam_files(), relay_heartbeat()/relay_stop()/is_relay_active(), RELAY_LEASE_SECONDS/_relay_lease_until, and the relay-lease check inside _upload_dashcam_files()/get_status(). Kept the chunked upload protocol, dashcam_hash_cache/_hash_dashcam_file() (still used by the Pi's own background uploader), and DASHCAM_CHUNK_SIZE - none of that was relay-specific. - templates/dash.html: removed the 'Relay via phone' card. - static/js/dash.js: removed all relay JS (relayOneFile, fetchChunkWithRetry, uploadChunkWithRetry, heartbeat timers, pending-file list rendering). - server/app.py: removed the CORS after_request hook, added solely for the phone's cross-origin browser calls (the Pi's own Python requests-based client was never subject to CORS). - readme.md: dropped the phone-relay feature bullet, updated the project-status blurb to explain the removal + point at the planned native Android app as the real fix for that use case. - PLAN.md: marked Phase 12 done with a verification writeup. Verified: py_compile on all touched Python files, node --check on dash.js, grepped all touched files for any leftover relay/RELAY reference (none), and smoke-tested by importing app.py + hitting it with Flask's test client - app starts cleanly, every relay route is gone from url_map, and both / and /dash still render HTTP 200 with zero 'relay' occurrences in /dash's HTML. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -121,262 +121,3 @@ dashcamToggle.addEventListener('change', () => {
|
||||
|
||||
refreshUploadStatus();
|
||||
setInterval(refreshUploadStatus, 15000);
|
||||
|
||||
// --- Relay via phone ---
|
||||
// Lets a phone (open on this same page, over the Pi's hotspot/LAN) download
|
||||
// pending dashcam files from the Pi and re-upload them directly to the
|
||||
// remote server using its own network, reusing the exact same chunked
|
||||
// upload wire protocol the Pi's own background uploader uses
|
||||
// (/api/v1/dashcam/upload/status + /chunk) - just orchestrated by this
|
||||
// browser instead of the Pi's Python client. Downloads are pulled in the
|
||||
// same chunk size and immediately re-uploaded, one at a time, so the phone
|
||||
// never has to hold a whole (300+MB) file in memory at once.
|
||||
|
||||
const relayStatusEl = document.getElementById('relay-status');
|
||||
const relayRefreshBtn = document.getElementById('relay-refresh-btn');
|
||||
const relayAllBtn = document.getElementById('relay-all-btn');
|
||||
const relayStopBtn = document.getElementById('relay-stop-btn');
|
||||
const relayFileList = document.getElementById('relay-file-list');
|
||||
|
||||
let relayConfig = null;
|
||||
let pendingFiles = [];
|
||||
const relayRowEls = new Map(); // relative_path -> { statusSpan, btn }
|
||||
let heartbeatTimer = null;
|
||||
let activeRelayCount = 0;
|
||||
let relayStopRequested = false;
|
||||
|
||||
function humanSize(bytes) {
|
||||
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function getRelayConfig() {
|
||||
if (relayConfig) return Promise.resolve(relayConfig);
|
||||
return fetch('/api/dash/relay/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
relayConfig = config;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
activeRelayCount++;
|
||||
relayStopBtn.disabled = false;
|
||||
if (heartbeatTimer) return;
|
||||
fetch('/api/dash/relay/heartbeat', { method: 'POST' }).catch(() => {});
|
||||
heartbeatTimer = setInterval(() => {
|
||||
fetch('/api/dash/relay/heartbeat', { method: 'POST' }).catch(() => {});
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopHeartbeatIfIdle() {
|
||||
activeRelayCount = Math.max(0, activeRelayCount - 1);
|
||||
if (activeRelayCount > 0) return;
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
relayStopBtn.disabled = true;
|
||||
fetch('/api/dash/relay/stop', { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
|
||||
relayStopBtn.addEventListener('click', () => {
|
||||
relayStopRequested = true;
|
||||
activeRelayCount = 0;
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
fetch('/api/dash/relay/stop', { method: 'POST' }).catch(() => {});
|
||||
relayStatusEl.textContent = 'Relay stopped - the Pi will resume its own uploads shortly.';
|
||||
relayStopBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Best-effort: try to release the pause lease immediately if the page is
|
||||
// closed mid-relay (it also self-expires after ~45s either way).
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (heartbeatTimer && navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/api/dash/relay/stop');
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchChunkWithRetry(relativePath, start, end, attempts = 3) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`/api/dash/file/${encodeURIComponent(relativePath)}`, {
|
||||
headers: { Range: `bytes=${start}-${end}` },
|
||||
});
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error(`download HTTP ${response.status}`);
|
||||
}
|
||||
return await response.blob();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await new Promise(r => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function uploadChunkWithRetry(config, relativePath, sha256, chunkIndex, totalChunks, blob, attempts = 3) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('relative_path', relativePath);
|
||||
form.append('sha256', sha256);
|
||||
form.append('chunk_index', String(chunkIndex));
|
||||
form.append('total_chunks', String(totalChunks));
|
||||
form.append('file', blob, `chunk_${chunkIndex}`);
|
||||
const response = await fetch(`${config.server_url}/api/v1/dashcam/upload/chunk`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Device-Name': config.device_name, 'X-Api-Key': config.api_key },
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`upload HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await new Promise(r => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function relayOneFile(file, statusEl) {
|
||||
const config = await getRelayConfig();
|
||||
if (!config.configured) {
|
||||
statusEl.textContent = 'Remote server not configured on this device.';
|
||||
return;
|
||||
}
|
||||
|
||||
relayStopRequested = false;
|
||||
startHeartbeat();
|
||||
try {
|
||||
const statusResp = await fetch(
|
||||
`${config.server_url}/api/v1/dashcam/upload/status?relative_path=${encodeURIComponent(file.relative_path)}&sha256=${file.sha256}`,
|
||||
{ headers: { 'X-Device-Name': config.device_name, 'X-Api-Key': config.api_key } }
|
||||
);
|
||||
if (!statusResp.ok) throw new Error(`status check HTTP ${statusResp.status}`);
|
||||
const status = await statusResp.json();
|
||||
|
||||
if (status.status !== 'already_uploaded') {
|
||||
const received = new Set(status.received_chunk_indices || []);
|
||||
const totalChunks = Math.max(1, Math.ceil(file.size / config.chunk_size));
|
||||
|
||||
for (let idx = 0; idx < totalChunks; idx++) {
|
||||
if (relayStopRequested) {
|
||||
statusEl.textContent = 'Stopped.';
|
||||
return;
|
||||
}
|
||||
if (received.has(idx)) continue;
|
||||
|
||||
const start = idx * config.chunk_size;
|
||||
const end = Math.min(start + config.chunk_size, file.size) - 1;
|
||||
statusEl.textContent = `Relaying... chunk ${idx + 1}/${totalChunks}`;
|
||||
|
||||
const blob = await fetchChunkWithRetry(file.relative_path, start, end);
|
||||
await uploadChunkWithRetry(config, file.relative_path, file.sha256, idx, totalChunks, blob);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Pi's own local dedupe table right away so the pending
|
||||
// list (and its own background uploader) reflect this immediately,
|
||||
// instead of waiting for its next full backlog scan.
|
||||
await fetch('/api/dash/relay/mark_uploaded', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relative_path: file.relative_path, sha256: file.sha256 }),
|
||||
}).catch(() => {});
|
||||
|
||||
statusEl.textContent = 'Done.';
|
||||
pendingFiles = pendingFiles.filter(f => f.relative_path !== file.relative_path);
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Error: ' + err;
|
||||
throw err;
|
||||
} finally {
|
||||
stopHeartbeatIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingFiles() {
|
||||
relayFileList.innerHTML = '';
|
||||
relayRowEls.clear();
|
||||
|
||||
if (pendingFiles.length === 0) {
|
||||
relayStatusEl.textContent = 'No pending dashcam files to relay.';
|
||||
relayAllBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
relayStatusEl.textContent = `${pendingFiles.length} file(s) pending upload.`;
|
||||
relayAllBtn.disabled = false;
|
||||
|
||||
pendingFiles.forEach(file => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${file.relative_path} (${humanSize(file.size)})`;
|
||||
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.className = 'text-muted small';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-outline-primary';
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Relay';
|
||||
btn.addEventListener('click', () => {
|
||||
btn.disabled = true;
|
||||
relayOneFile(file, statusSpan).finally(() => {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
li.appendChild(label);
|
||||
li.appendChild(statusSpan);
|
||||
li.appendChild(btn);
|
||||
relayFileList.appendChild(li);
|
||||
relayRowEls.set(file.relative_path, { statusSpan, btn });
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPendingFiles() {
|
||||
relayStatusEl.textContent = 'Checking pending files...';
|
||||
fetch('/api/dash/pending')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
pendingFiles = data.files || [];
|
||||
renderPendingFiles();
|
||||
})
|
||||
.catch(err => {
|
||||
relayStatusEl.textContent = 'Error while checking pending files: ' + err;
|
||||
});
|
||||
}
|
||||
|
||||
relayRefreshBtn.addEventListener('click', refreshPendingFiles);
|
||||
|
||||
relayAllBtn.addEventListener('click', async () => {
|
||||
relayStopRequested = false;
|
||||
relayAllBtn.disabled = true;
|
||||
for (const file of pendingFiles.slice()) {
|
||||
if (relayStopRequested) break;
|
||||
const row = relayRowEls.get(file.relative_path);
|
||||
const statusSpan = row ? row.statusSpan : relayStatusEl;
|
||||
if (row) row.btn.disabled = true;
|
||||
try {
|
||||
await relayOneFile(file, statusSpan);
|
||||
} catch (err) {
|
||||
// Keep going with the remaining files even if one fails.
|
||||
}
|
||||
}
|
||||
relayAllBtn.disabled = pendingFiles.length === 0;
|
||||
});
|
||||
|
||||
refreshPendingFiles();
|
||||
|
||||
Reference in New Issue
Block a user