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:
Mathieu Moran
2026-08-09 17:12:30 -04:00
co-authored by Copilot
parent 0b729adb28
commit f17c5c8681
7 changed files with 46 additions and 430 deletions
+26 -15
View File
@@ -643,23 +643,34 @@ workaround currently in place for that specific case.
**Side-fix from this test session**: found and fixed a real, unrelated bug in `yaris-netwatch.sh` **Side-fix from this test session**: found and fixed a real, unrelated bug in `yaris-netwatch.sh`
during this same test — see the Phase 6 update above. during this same test — see the Phase 6 update above.
### Phase 12: Remove phone-relay feature — 🚧 PLANNED ### Phase 12: Remove phone-relay feature — ✅ DONE
Undo Phase 11 cleanly given the Phase 11 update above (fundamental browser platform limitation, Undo Phase 11 cleanly given the Phase 11 update above (fundamental browser platform limitation,
not fixable). Remove: not fixable). Removed:
- `app.py`: `/api/dash/pending`, `/api/dash/file/<path>`, `/api/dash/relay/config`, - `app.py`: `/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. `/api/dash/relay/heartbeat`, `/api/dash/relay/stop`, `/api/dash/relay/mark_uploaded` routes
- `modules/app_upload.py`: `list_pending_dashcam_files()`, `_hash_dashcam_file()`/hash-cache (and the now-unused `os`/`send_file`/`abort`/`safe_join` imports they needed).
plumbing if unused elsewhere, `relay_heartbeat()`/`relay_stop()`/`is_relay_active()`, - `modules/app_upload.py`: `list_pending_dashcam_files()`, `relay_heartbeat()`/`relay_stop()`/
`RELAY_LEASE_SECONDS`, and the `is_relay_active()` check inside `_upload_dashcam_files()`. `is_relay_active()`, `RELAY_LEASE_SECONDS`/`_relay_lease_until`, and the `is_relay_active()`
- `templates/dash.html` / `static/js/dash.js`: the "Relay via phone" card and all its JS check inside `_upload_dashcam_files()`/`get_status()`.
(`relayOneFile`, `fetchChunkWithRetry`, `uploadChunkWithRetry`, heartbeat timers, etc). - `templates/dash.html`: the "Relay via phone" card.
- `server/app.py`: the CORS `after_request` hook was added solely for the phone's cross-origin - `static/js/dash.js`: all relay JS (`relayOneFile`, `fetchChunkWithRetry`,
browser calls — remove it too (the Pi's own Python `requests`-based client was never `uploadChunkWithRetry`, heartbeat timers, pending-file list rendering, etc).
subject to CORS, that's a browser-only enforcement mechanism). - `server/app.py`: the CORS `after_request` hook, added solely for the phone's cross-origin
- Keep untouched: the chunked upload protocol itself (`/api/v1/dashcam/upload/chunk` + browser calls (the Pi's own Python `requests`-based client was never subject to CORS - that's
`/status`), `dashcam_hash_cache` table (still used by the Pi's own background uploader to a browser-only enforcement mechanism).
avoid rehashing its 20GB+ backlog every loop), `DASHCAM_CHUNK_SIZE`/Cloudflare 100MB - Kept untouched, as planned: the chunked upload protocol itself
workaround - none of that is relay-specific. (`/api/v1/dashcam/upload/chunk` + `/status`), `dashcam_hash_cache` table + `_hash_dashcam_file()`
(still used by the Pi's own background uploader to avoid rehashing its 20GB+ backlog every
loop), `DASHCAM_CHUNK_SIZE`/Cloudflare 100MB workaround - none of that is relay-specific.
**Verified**: `python3 -m py_compile` on all touched Python files, `node --check` on `dash.js`,
grepped all five touched files for any leftover `relay`/`RELAY` reference (none found). Smoke-
tested by actually importing `app.py` in a scratch venv and hitting it with Flask's test client:
app imports cleanly (GPS/OBD/SQL/upload startup all log their normal "no hardware" messages, no
crash), `app.url_map` confirms every relay route is gone while `/`, `/dash`, `/dtc`, and the
remaining dashcam/upload API routes are still registered, and both `/` and `/dash` render with
HTTP 200 (`/dash`'s HTML contains zero occurrences of "relay").
### Phase 13: Power-loss resilience & watchdog — 🚧 PLANNED ### Phase 13: Power-loss resilience & watchdog — 🚧 PLANNED
**New phase, not code-dependent on anything else — OS/hardware-level, can be done anytime, **New phase, not code-dependent on anything else — OS/hardware-level, can be done anytime,
+1 -58
View File
@@ -1,7 +1,4 @@
import os from flask import Flask, render_template, make_response, json, jsonify, request
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, abort
from werkzeug.utils import safe_join
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload
import time import time
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@@ -53,60 +50,6 @@ def upload_dashcam_toggle():
app_upload.set_dashcam_upload_enabled(bool(body["enabled"])) app_upload.set_dashcam_upload_enabled(bool(body["enabled"]))
return jsonify(app_upload.get_status()) return jsonify(app_upload.get_status())
# --- Phone-relay: lets a phone on the Pi's own network list, download, and
# directly re-upload dashcam files to the remote server itself (bypassing
# the Pi's own often-congested WiFi radio for the internet-bound leg). See
# static/js/dash.js for the client side of this. ---
@app.route('/api/dash/pending')
def dash_pending():
return jsonify({"files": app_upload.list_pending_dashcam_files()})
@app.route('/api/dash/file/<path:relpath>')
def dash_file(relpath):
abs_path = safe_join(app_dash.DEFAULT_DESTINATION, relpath)
if abs_path is None or not os.path.isfile(abs_path):
abort(404)
# conditional=True (Flask's default) makes this support Range requests,
# so the phone can fetch/re-upload one chunk at a time instead of ever
# holding the whole (300+MB) file in the browser's memory at once.
return send_file(abs_path, mimetype='application/octet-stream', conditional=True)
@app.route('/api/dash/relay/config')
def dash_relay_config():
# Hands the phone the same device credentials the Pi's own upload client
# uses, so its browser JS can call the remote server's existing chunked
# upload endpoints directly. Same trust boundary as the rest of this
# unauthenticated local UI - anyone on the Pi's hotspot/LAN is already
# implicitly trusted.
return jsonify({
"server_url": app_upload.SERVER_URL,
"device_name": app_upload.DEVICE_NAME,
"api_key": app_upload.API_KEY,
"chunk_size": app_upload.DASHCAM_CHUNK_SIZE,
"configured": app_upload.is_configured(),
})
@app.route('/api/dash/relay/heartbeat', methods=['POST'])
def dash_relay_heartbeat():
app_upload.relay_heartbeat()
return jsonify(app_upload.get_status())
@app.route('/api/dash/relay/stop', methods=['POST'])
def dash_relay_stop():
app_upload.relay_stop()
return jsonify(app_upload.get_status())
@app.route('/api/dash/relay/mark_uploaded', methods=['POST'])
def dash_relay_mark_uploaded():
body = request.get_json(silent=True) or {}
relative_path = (body.get("relative_path") or "").strip()
sha256 = (body.get("sha256") or "").strip().lower()
if not relative_path or not sha256:
return jsonify(error="expected JSON body {'relative_path', 'sha256'}"), 400
app_sql.mark_dashcam_file_uploaded(relative_path, sha256)
return jsonify(status="ok")
# not sure if im gonna use this # not sure if im gonna use this
@app.route('/api/obd_speed/') @app.route('/api/obd_speed/')
def data(): def data():
+8 -55
View File
@@ -67,17 +67,10 @@ DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled" DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
# How long a phone-relay "lease" stays valid without a fresh heartbeat before
# the Pi's own background uploader assumes the phone gave up/closed its tab
# and resumes uploading dashcam files itself. The phone page is expected to
# heartbeat well under this (every ~15-20s) - see /api/dash/relay/heartbeat.
RELAY_LEASE_SECONDS = 45
_upload_thread = None _upload_thread = None
_last_error = None _last_error = None
_last_success_at = None _last_success_at = None
_last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on _last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on
_relay_lease_until = None # monotonic time.time() the phone-relay lease expires, or None
def _headers(): def _headers():
@@ -107,30 +100,9 @@ def get_status():
"last_success_at": _last_success_at, "last_success_at": _last_success_at,
"last_network_path": _last_network_path, "last_network_path": _last_network_path,
"cellular_fallback_configured": app_cellular.is_configured(), "cellular_fallback_configured": app_cellular.is_configured(),
"relay_active": is_relay_active(),
} }
# --- Phone-relay lease (pauses the Pi's own background dashcam uploader
# while a phone is manually relaying files, so they don't fight over the
# Pi's single, often WiFi-congested, radio) -------------------------------
def relay_heartbeat():
"""Called by the phone-relay page every ~15-20s while active."""
global _relay_lease_until
_relay_lease_until = time.time() + RELAY_LEASE_SECONDS
def relay_stop():
"""Called when the phone-relay page is done/closed cleanly."""
global _relay_lease_until
_relay_lease_until = None
def is_relay_active():
return _relay_lease_until is not None and time.time() < _relay_lease_until
def _sha256_file(path): def _sha256_file(path):
hasher = hashlib.sha256() hasher = hashlib.sha256()
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -145,9 +117,8 @@ def _sha256_file(path):
def _hash_dashcam_file(abs_path, rel_path): def _hash_dashcam_file(abs_path, rel_path):
"""sha256 of a dashcam file, cached by (path, size, mtime) so unchanged """sha256 of a dashcam file, cached by (path, size, mtime) so unchanged
files already copied from the dashcam never get rehashed - real backlogs files already copied from the dashcam never get rehashed - real backlogs
here run 20GB+, and both the background loop and the phone-relay pending here run 20GB+, and the background upload loop would otherwise rehash
list would otherwise rehash everything, including already-uploaded files, everything, including already-uploaded files, on every single call."""
on every single call."""
stat = os.stat(abs_path) stat = os.stat(abs_path)
cached = app_sql.get_cached_dashcam_hash(rel_path, stat.st_size, stat.st_mtime) cached = app_sql.get_cached_dashcam_hash(rel_path, stat.st_size, stat.st_mtime)
if cached is not None: if cached is not None:
@@ -157,22 +128,6 @@ def _hash_dashcam_file(abs_path, rel_path):
return sha256 return sha256
def list_pending_dashcam_files():
"""Return [{relative_path, sha256, size}, ...] for dashcam files not yet
confirmed uploaded - used by the phone-relay page to pick what to fetch."""
pending = []
for abs_path, rel_path in app_dash.find_video_files(app_dash.DEFAULT_DESTINATION):
sha256 = _hash_dashcam_file(abs_path, rel_path)
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
continue
pending.append({
"relative_path": rel_path,
"sha256": sha256,
"size": os.path.getsize(abs_path),
})
return pending
def _upload_tracking_batch(): def _upload_tracking_batch():
"""Send up to TRACKING_BATCH_SIZE not-yet-uploaded rows. Returns count sent. """Send up to TRACKING_BATCH_SIZE not-yet-uploaded rows. Returns count sent.
@@ -290,11 +245,10 @@ def _upload_video_chunked(abs_path, rel_path, sha256):
def _upload_dashcam_files(): def _upload_dashcam_files():
"""Upload any not-yet-uploaded dashcam files, one at a time. Returns count sent. """Upload any not-yet-uploaded dashcam files, one at a time. Returns count sent.
Skipped entirely (returns 0) while the dashcam-upload toggle is off, or Skipped entirely (returns 0) while the dashcam-upload toggle is off -
while a phone-relay session is actively leased - tracking-log uploads are tracking-log uploads are unaffected.
unaffected by either of these.
""" """
if not is_dashcam_upload_enabled() or is_relay_active(): if not is_dashcam_upload_enabled():
return 0 return 0
uploaded_count = 0 uploaded_count = 0
@@ -307,10 +261,9 @@ def _upload_dashcam_files():
app_sql.mark_dashcam_file_uploaded(rel_path, sha256) app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
uploaded_count += 1 uploaded_count += 1
# Re-check between files so flipping the toggle off, or a phone # Re-check between files so flipping the toggle off mid-run stops
# relay starting, mid-run stops further uploads promptly without # further uploads promptly without waiting for the next cycle.
# waiting for the next cycle. if not is_dashcam_upload_enabled():
if not is_dashcam_upload_enabled() or is_relay_active():
break break
return uploaded_count return uploaded_count
+11 -9
View File
@@ -42,9 +42,6 @@ i have a dream
Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps
the Pi's memory footprint flat regardless of file size); a UI toggle can pause dashcam uploads the Pi's memory footprint flat regardless of file size); a UI toggle can pause dashcam uploads
independently while trip logs keep syncing independently while trip logs keep syncing
- 📱 **Phone-relay upload** — if the Pi's own WiFi is slow/congested, open `/dash` on your phone
to download pending dashcam files locally and re-upload them straight to the remote server
using the phone's own network instead, automatically pausing the Pi's own upload meanwhile
- 🗄️ Local SQLite trip logging, retried/synced to the remote server with automatic backoff - 🗄️ Local SQLite trip logging, retried/synced to the remote server with automatic backoff
- 🔌 Runs fully headless as a `systemd` service, autostarting on boot - 🔌 Runs fully headless as a `systemd` service, autostarting on boot
@@ -78,9 +75,14 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README
## Project status ## Project status
This project was revived after a couple of years on the shelf. All 11 planned phases are done and This project was revived after a couple of years on the shelf. The first 11 planned phases are
the full pipeline — OBD/GPS logging, offline web UI, WiFi hotspot, dashcam retrieval, remote done and the full pipeline — OBD/GPS logging, offline web UI, WiFi hotspot, dashcam retrieval, and
upload, and phone-relay upload — has been verified against real hardware end-to-end, including a remote upload — has been verified against real hardware end-to-end, including a full real dashcam
full real dashcam import and upload run. Only the SIM868 cellular fallback (for uploading while import and upload run. A phone-relay upload feature was built and tested but ultimately removed:
out of WiFi range) and the phone-relay feature's real-phone/live-network test remain untested. real-world testing showed it hit a fundamental phone-OS limitation (a browser page can't use WiFi
Full phase-by-phase history in [PLAN.md](./PLAN.md). and cellular simultaneously), so it couldn't actually deliver on its "upload from the road without
the Pi's slow WiFi" goal — see [PLAN.md](./PLAN.md) for the full writeup. That capability is now
planned as a native Android app (further down the roadmap) instead. Only the SIM868 cellular
fallback (for uploading while out of WiFi range) remains untested (no SIM card available yet).
Full phase-by-phase history and the current roadmap (dashcam/video file browsers, OBD hardening,
power-loss resilience, trip history, alerts, and more) are in [PLAN.md](./PLAN.md).
-14
View File
@@ -12,20 +12,6 @@ from modules.auth import require_device_auth
app = Flask(__name__) app = Flask(__name__)
# The phone-relay feature (Pi web UI) has a phone's browser call this API
# directly from a page served BY THE PI (a different origin than this
# server), so the browser enforces CORS on those cross-origin requests.
# Wildcard is fine here - this API is authenticated via the X-Device-Name/
# X-Api-Key headers (not cookies), so there's no session/credential to leak
# via a permissive Access-Control-Allow-Origin.
@app.after_request
def _add_cors_headers(response):
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, X-Device-Name, X-Api-Key"
response.headers["Access-Control-Max-Age"] = "600"
return response
# Where uploaded dashcam files get written on disk (mounted to the unraid # Where uploaded dashcam files get written on disk (mounted to the unraid
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env). # array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam") STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
-259
View File
@@ -121,262 +121,3 @@ dashcamToggle.addEventListener('change', () => {
refreshUploadStatus(); refreshUploadStatus();
setInterval(refreshUploadStatus, 15000); 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();
-20
View File
@@ -97,26 +97,6 @@
</p> </p>
</div> </div>
</div> </div>
<div class="card shadow mt-4">
<div class="card-header py-3">
<p class="text-primary m-0 fw-bold">Relay via phone</p>
</div>
<div class="card-body">
<p class="text-muted small">
Open this page on your phone (while connected to the Pi's
hotspot/WiFi) to download pending dashcam files locally and
upload them straight to the remote server using your phone's own
data connection - useful when the Pi's own WiFi upload is slow.
Starting a relay automatically pauses the Pi's own background
dashcam upload so they don't compete for the same radio.
</p>
<p id="relay-status" class="mb-2">Checking...</p>
<button id="relay-refresh-btn" class="btn btn-secondary btn-sm" type="button">Refresh pending files</button>
<button id="relay-all-btn" class="btn btn-primary btn-sm" type="button" disabled>Relay all pending</button>
<button id="relay-stop-btn" class="btn btn-outline-danger btn-sm" type="button" disabled>Stop relay</button>
<ul id="relay-file-list" class="list-group mt-3"></ul>
</div>
</div>
</div> </div>
</div> </div>
<footer class="bg-white sticky-footer"> <footer class="bg-white sticky-footer">