diff --git a/PLAN.md b/PLAN.md index c8e0a2a..893d6e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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/`, `/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, diff --git a/app.py b/app.py index df5c29b..a04bad6 100644 --- a/app.py +++ b/app.py @@ -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/') -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(): diff --git a/modules/app_upload.py b/modules/app_upload.py index fcde69a..816f3a3 100644 --- a/modules/app_upload.py +++ b/modules/app_upload.py @@ -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 diff --git a/readme.md b/readme.md index 0ffc6dd..90e5d51 100644 --- a/readme.md +++ b/readme.md @@ -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). diff --git a/server/app.py b/server/app.py index 4fc2d04..b657633 100644 --- a/server/app.py +++ b/server/app.py @@ -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") diff --git a/static/js/dash.js b/static/js/dash.js index 1dc0576..3e3ed33 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -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(); diff --git a/templates/dash.html b/templates/dash.html index a13918c..9402fd4 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -97,26 +97,6 @@

-
-
-

Relay via phone

-
-
-

- 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. -

-

Checking...

- - - -
    -
    -