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:
@@ -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`
|
||||
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,
|
||||
not fixable). Remove:
|
||||
not fixable). Removed:
|
||||
- `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.
|
||||
- `modules/app_upload.py`: `list_pending_dashcam_files()`, `_hash_dashcam_file()`/hash-cache
|
||||
plumbing if unused elsewhere, `relay_heartbeat()`/`relay_stop()`/`is_relay_active()`,
|
||||
`RELAY_LEASE_SECONDS`, and the `is_relay_active()` check inside `_upload_dashcam_files()`.
|
||||
- `templates/dash.html` / `static/js/dash.js`: the "Relay via phone" card and all its JS
|
||||
(`relayOneFile`, `fetchChunkWithRetry`, `uploadChunkWithRetry`, heartbeat timers, etc).
|
||||
- `server/app.py`: the CORS `after_request` hook was added solely for the phone's cross-origin
|
||||
browser calls — remove it too (the Pi's own Python `requests`-based client was never
|
||||
subject to CORS, that's a browser-only enforcement mechanism).
|
||||
- Keep untouched: the chunked upload protocol itself (`/api/v1/dashcam/upload/chunk` +
|
||||
`/status`), `dashcam_hash_cache` table (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.
|
||||
`/api/dash/relay/heartbeat`, `/api/dash/relay/stop`, `/api/dash/relay/mark_uploaded` routes
|
||||
(and the now-unused `os`/`send_file`/`abort`/`safe_join` imports they needed).
|
||||
- `modules/app_upload.py`: `list_pending_dashcam_files()`, `relay_heartbeat()`/`relay_stop()`/
|
||||
`is_relay_active()`, `RELAY_LEASE_SECONDS`/`_relay_lease_until`, and the `is_relay_active()`
|
||||
check inside `_upload_dashcam_files()`/`get_status()`.
|
||||
- `templates/dash.html`: the "Relay via phone" card.
|
||||
- `static/js/dash.js`: all relay JS (`relayOneFile`, `fetchChunkWithRetry`,
|
||||
`uploadChunkWithRetry`, heartbeat timers, pending-file list rendering, etc).
|
||||
- `server/app.py`: 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 - that's
|
||||
a browser-only enforcement mechanism).
|
||||
- Kept untouched, as planned: the chunked upload protocol itself
|
||||
(`/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
|
||||
**New phase, not code-dependent on anything else — OS/hardware-level, can be done anytime,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import os
|
||||
|
||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, abort
|
||||
from werkzeug.utils import safe_join
|
||||
from flask import Flask, render_template, make_response, json, jsonify, request
|
||||
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload
|
||||
import time
|
||||
from gevent.pywsgi import WSGIServer
|
||||
@@ -53,60 +50,6 @@ def upload_dashcam_toggle():
|
||||
app_upload.set_dashcam_upload_enabled(bool(body["enabled"]))
|
||||
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
|
||||
@app.route('/api/obd_speed/')
|
||||
def data():
|
||||
|
||||
+8
-55
@@ -67,17 +67,10 @@ DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
|
||||
|
||||
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
|
||||
_last_error = None
|
||||
_last_success_at = None
|
||||
_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():
|
||||
@@ -107,30 +100,9 @@ def get_status():
|
||||
"last_success_at": _last_success_at,
|
||||
"last_network_path": _last_network_path,
|
||||
"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):
|
||||
hasher = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
@@ -145,9 +117,8 @@ def _sha256_file(path):
|
||||
def _hash_dashcam_file(abs_path, rel_path):
|
||||
"""sha256 of a dashcam file, cached by (path, size, mtime) so unchanged
|
||||
files already copied from the dashcam never get rehashed - real backlogs
|
||||
here run 20GB+, and both the background loop and the phone-relay pending
|
||||
list would otherwise rehash everything, including already-uploaded files,
|
||||
on every single call."""
|
||||
here run 20GB+, and the background upload loop would otherwise rehash
|
||||
everything, including already-uploaded files, on every single call."""
|
||||
stat = os.stat(abs_path)
|
||||
cached = app_sql.get_cached_dashcam_hash(rel_path, stat.st_size, stat.st_mtime)
|
||||
if cached is not None:
|
||||
@@ -157,22 +128,6 @@ def _hash_dashcam_file(abs_path, rel_path):
|
||||
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():
|
||||
"""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():
|
||||
"""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
|
||||
while a phone-relay session is actively leased - tracking-log uploads are
|
||||
unaffected by either of these.
|
||||
Skipped entirely (returns 0) while the dashcam-upload toggle is off -
|
||||
tracking-log uploads are unaffected.
|
||||
"""
|
||||
if not is_dashcam_upload_enabled() or is_relay_active():
|
||||
if not is_dashcam_upload_enabled():
|
||||
return 0
|
||||
|
||||
uploaded_count = 0
|
||||
@@ -307,10 +261,9 @@ def _upload_dashcam_files():
|
||||
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
||||
uploaded_count += 1
|
||||
|
||||
# Re-check between files so flipping the toggle off, or a phone
|
||||
# relay starting, mid-run stops further uploads promptly without
|
||||
# waiting for the next cycle.
|
||||
if not is_dashcam_upload_enabled() or is_relay_active():
|
||||
# Re-check between files so flipping the toggle off mid-run stops
|
||||
# further uploads promptly without waiting for the next cycle.
|
||||
if not is_dashcam_upload_enabled():
|
||||
break
|
||||
|
||||
return uploaded_count
|
||||
|
||||
@@ -42,9 +42,6 @@ i have a dream
|
||||
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
|
||||
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
|
||||
- 🔌 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
|
||||
|
||||
This project was revived after a couple of years on the shelf. All 11 planned phases are done and
|
||||
the full pipeline — OBD/GPS logging, offline web UI, WiFi hotspot, dashcam retrieval, remote
|
||||
upload, and phone-relay upload — has been verified against real hardware end-to-end, including a
|
||||
full real dashcam import and upload run. Only the SIM868 cellular fallback (for uploading while
|
||||
out of WiFi range) and the phone-relay feature's real-phone/live-network test remain untested.
|
||||
Full phase-by-phase history in [PLAN.md](./PLAN.md).
|
||||
This project was revived after a couple of years on the shelf. The first 11 planned phases are
|
||||
done and the full pipeline — OBD/GPS logging, offline web UI, WiFi hotspot, dashcam retrieval, and
|
||||
remote upload — has been verified against real hardware end-to-end, including a full real dashcam
|
||||
import and upload run. A phone-relay upload feature was built and tested but ultimately removed:
|
||||
real-world testing showed it hit a fundamental phone-OS limitation (a browser page can't use WiFi
|
||||
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).
|
||||
|
||||
@@ -12,20 +12,6 @@ from modules.auth import require_device_auth
|
||||
|
||||
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
|
||||
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
|
||||
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -97,26 +97,6 @@
|
||||
</p>
|
||||
</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>
|
||||
<footer class="bg-white sticky-footer">
|
||||
|
||||
Reference in New Issue
Block a user