From 3a6f12ba9d65e95526413623ee33bbc096f640e4 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 9 Aug 2026 21:26:15 -0400 Subject: [PATCH] Add local settings management Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLAN.md | 55 +++++++++- app.py | 19 +++- modules/app_alerts.py | 45 +++++++- modules/app_cellular.py | 85 ++++++++++++--- modules/app_settings.py | 205 +++++++++++++++++++++++++++++++++++++ modules/app_sql.py | 18 ++++ readme.md | 7 ++ static/js/dash.js | 56 ---------- static/js/settings.js | 172 +++++++++++++++++++++++++++++++ templates/base.html | 5 + templates/dash.html | 21 ---- templates/settings.html | 132 ++++++++++++++++++++++++ tests/test_app_alerts.py | 116 +++++++++++++++++++++ tests/test_app_cellular.py | 114 +++++++++++++++++++++ tests/test_app_settings.py | 171 +++++++++++++++++++++++++++++++ 15 files changed, 1118 insertions(+), 103 deletions(-) create mode 100644 modules/app_settings.py create mode 100644 static/js/settings.js create mode 100644 templates/settings.html create mode 100644 tests/test_app_cellular.py create mode 100644 tests/test_app_settings.py diff --git a/PLAN.md b/PLAN.md index 1f0d79a..bd512aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1053,7 +1053,7 @@ that could contain a private topic URL. badge counts, alert titles/details, no-alert state, synthetic monitor-failure state, shared-shell availability, and no-horizontal-overflow behavior passed with no new console errors. -### Phase 21: Settings page — 🚧 PLANNED +### Phase 21: Settings page — ✅ DONE **New phase - depends on Phase 15's shared components, and benefits from Phases 17/20 existing** so there's real configuration to consolidate. Several settings currently only exist as SSH-edited files or scattered individual toggles (dashcam upload pause switch on `/dash`, @@ -1064,6 +1064,34 @@ upload toggle, cellular APN, local video retention policy, alert webhook URL + t SSID/password changes likely stay out of scope (higher-risk to expose over a UI that could lock the Pi out of its own network if fat-fingered) unless it turns out to be easy/safe to add later. +**Implemented and hardware-tested 2026-08-09.** Added a dedicated local `/settings` page and +validated `/api/settings` endpoint backed by the existing SQLite settings table. It consolidates +the dashcam-upload toggle, uploaded-video retention days, SIM868 cellular enable/APN, optional +ntfy enable/URL/token, and alert temperature/disk/backlog/GPS-loss/repeat thresholds. Updates are +validated as a complete group and committed atomically; invalid requests leave the prior +configuration untouched. Cellular enable/APN reads persisted settings live, so those changes do +not need an app restart. Sensitive ntfy URL/token values are write-only through the API: the page +only reports whether each is configured and supports explicit removal, so saved secrets are never +sent back to browser JavaScript. Local Web UI alerts remain active independently of ntfy. + +When cellular is configured, ntfy delivery now uses the SIM868's small-body HTTP client as the +primary transport with the notification title, priority, tags, and optional bearer token. +WiFi/Ethernet is the fallback if the modem path fails. The generalized modem POST path rejects +quotes/newlines in AT-command parameters before touching hardware. This is mock/Pi-code tested but +remains explicitly unverified over a real carrier until a SIM is available; Phase 24 must prove +authenticated HTTPS, fallback, retry, and recovery behavior or remove ntfy entirely if the SIM868 +cannot deliver it reliably. + +Moved the dashcam-upload control from `/dash` into Settings rather than keeping duplicate controls, +while retaining pending-row, last-success/path, and last-error upload health in the new page. +WiFi credentials, remote-server device/API credentials, cellular username/password, and other +lockout/high-risk configuration remain file/SSH managed by design. Focused settings/alert tests +pass locally and on the Pi; the real Pi rejected an invalid update with HTTP 400 while preserving +its exact configuration. The live page loaded through `yaris.local` with the Pi's existing +defaults and shared alert/navigation shell. Browser testing at 390x844 found and fixed an initial +top-bar overlap by adding a dedicated bottom mobile save action; the real save then completed with +no horizontal overflow or console errors. + ### Phase 22: Finish web UI redesign — 🚧 PLANNED **Roadmap decision 2026-08-09: keep this as Phase 22 rather than moving it forward.** Phase 15 already pulled the architecture-level work forward: every page now shares one Jinja shell and @@ -1131,6 +1159,18 @@ recorded trip details, and footage can be accessed away from the car: HTML/JavaScript. - Support selecting a vehicle/device if more than one is registered later, while defaulting cleanly to the current Yaris. Keep trip APIs scoped to the authenticated user/device. +- Synchronize alert transitions, recoveries, timestamps, severity/details, and current state from + the Pi to the server through an authenticated, idempotent offline queue. The Pi remains + authoritative for vehicle/hardware detection and its local bell must never depend on internet + access. The server stores per-vehicle alert history/current state, reconciles reconnects without + duplicates, and exposes it in the remote portal; remote acknowledgement must not silently clear + the underlying Pi condition. +- Synchronize alert transitions, recoveries, timestamps, severity/details, and current state from + the Pi to the server through an authenticated, idempotent offline queue. The Pi remains + authoritative for vehicle/hardware detection and its local bell must never depend on internet + access. The server stores per-vehicle alert history/current state, reconciles reconnects without + duplicates, and exposes it in the remote portal; remote acknowledgement must not silently clear + the underlying Pi condition. - Deploy only after Phase 22 is complete: rebuild the unraid Docker stack, verify migrations against copied data first, compare remote trip calculations with uploaded Pi records, verify playback, Range requests, and downloads against disposable copies of real server footage, and browser-test @@ -1187,8 +1227,13 @@ clean trip end: returns to recording afterward. Test deletion only with the disposable clip. - **Alerts:** safely exercise conditions that can be simulated without damaging hardware (upload backlog, temporary GPS loss while OBD reports motion, and disposable threshold overrides). - Confirm local bell transition/recovery/cooldown behavior. Do not intentionally overheat, - undervolt, fill storage, or trigger a real MIL. Verify ntfy only if it has been configured by then. + Confirm local bell transition/recovery/cooldown behavior and offline alert-state reconciliation + with the Phase 23 server after connectivity returns. Do not intentionally overheat, undervolt, + fill storage, or trigger a real MIL. With a real SIM, explicitly determine whether the SIM868 + transport can reliably deliver ntfy notifications while WiFi/Ethernet is unavailable, including + authentication, timeout, retry, and recovery. If that cannot be made reliable on the actual + modem/network, remove the ntfy feature and its settings rather than retain a transport that fails + in the only situation where it is useful. - **Power/reliability:** perform multiple normal ignition/power cycles, confirm automatic boot, service/watchdog startup, SIM868 power-on, OBD/GPS recovery, no SQLite corruption, and no current undervoltage/throttling. The deferred Phase 14 clean-shutdown limitation must be explicitly @@ -1201,7 +1246,9 @@ Verify the complete remote path after connectivity returns: remotely. - On the Phase 23 portal, verify the explicit **Remote server** identity, authenticated vehicle-scoped trip summary/replay, route and timeline agreement with the local Pi, archived clip - thumbnail/playback/Range seeking/download, and absence of local-only OBD/cellular/Pi controls. + thumbnail/playback/Range seeking/download, synchronized alert history/current state without + duplicate transitions after an offline interval, and absence of local-only OBD/cellular/Pi + controls. - The Android companion is intentionally outside this core acceptance gate and remains the final, low-priority Phase 25. Its own hotspot/cellular binding, trip display, and disposable clip relay require separate real-phone/in-car acceptance when that phase is implemented. diff --git a/app.py b/app.py index 5439ddc..bb6c982 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file -from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts +from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings import os, time from gevent.pywsgi import WSGIServer @@ -33,6 +33,19 @@ def dash(): def history(): return render_template('history.html', account=account, car=car) +@app.route('/settings') +def settings(): + return render_template('settings.html', account=account, car=car) + +@app.route('/api/settings', methods=['GET', 'POST']) +def api_settings(): + if request.method == 'GET': + return jsonify(app_settings.get_settings()) + errors = app_settings.update_settings(request.get_json(silent=True)) + if errors: + return jsonify(error="Invalid settings.", fields=errors), 400 + return jsonify(app_settings.get_settings()) + @app.route('/api/trips') def trips(): return jsonify({"trips": app_sql.list_trips()}) @@ -152,9 +165,9 @@ def alert_status(): @app.route('/api/upload/dashcam_toggle', methods=['POST']) def upload_dashcam_toggle(): body = request.get_json(silent=True) or {} - if "enabled" not in body: + if not isinstance(body.get("enabled"), bool): return jsonify(error="expected JSON body {'enabled': true|false}"), 400 - app_upload.set_dashcam_upload_enabled(bool(body["enabled"])) + app_upload.set_dashcam_upload_enabled(body["enabled"]) return jsonify(app_upload.get_status()) # not sure if im gonna use this diff --git a/modules/app_alerts.py b/modules/app_alerts.py index 3d8a3b9..875b159 100644 --- a/modules/app_alerts.py +++ b/modules/app_alerts.py @@ -6,7 +6,15 @@ from urllib.parse import urlparse import requests -from modules import app_dash, app_gps, app_health, app_obd, app_sql, app_video +from modules import ( + app_cellular, + app_dash, + app_gps, + app_health, + app_obd, + app_sql, + app_video, +) CHECK_INTERVAL = 60 @@ -94,9 +102,29 @@ def send_ntfy(url, token, title, message, priority="default", tags=None): headers["Tags"] = ",".join(tags) if token: headers["Authorization"] = f"Bearer {token}" + body = message.encode("utf-8") + if app_cellular.is_configured(): + cellular_failure = None + try: + status_code, _response_body = app_cellular.http_post( + url, + body, + headers, + content_type="text/plain; charset=utf-8", + ) + if 200 <= status_code < 300: + return + cellular_failure = f"HTTP {status_code}" + except app_cellular.CellularError: + cellular_failure = "modem error" + print( + f"Cellular notification failed ({cellular_failure}); " + "trying WiFi/Ethernet." + ) + response = requests.post( url, - data=message.encode("utf-8"), + data=body, headers=headers, timeout=5, ) @@ -104,6 +132,8 @@ def send_ntfy(url, token, title, message, priority="default", tags=None): def _transport_error(error): + if isinstance(error, app_cellular.CellularError): + return "Cellular notification failed" if isinstance(error, requests.Timeout): return "Notification request timed out" if isinstance(error, requests.HTTPError): @@ -398,7 +428,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy): condition["priority"], condition["tags"], ) - except requests.RequestException as error: + except ( + requests.RequestException, + app_cellular.CellularError, + ) as error: state["last_error"] = _transport_error(error) else: state["last_sent_at"] = now @@ -423,7 +456,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy): "default", ["white_check_mark"], ) - except requests.RequestException as error: + except ( + requests.RequestException, + app_cellular.CellularError, + ) as error: state["last_error"] = _transport_error(error) else: state["recovery_pending"] = False @@ -455,6 +491,7 @@ def _loop(): RuntimeError, sqlite3.Error, requests.RequestException, + app_cellular.CellularError, ) as error: _last_run_error = str(error) print("Alert check failed:", error) diff --git a/modules/app_cellular.py b/modules/app_cellular.py index 4cb9868..ee492d3 100644 --- a/modules/app_cellular.py +++ b/modules/app_cellular.py @@ -28,15 +28,11 @@ AT interface has a small internal buffer unsuited to multi-MB bodies. import os import time -from modules import app_gps - -APN = os.getenv("YARIS_CELLULAR_APN", "") -APN_USER = os.getenv("YARIS_CELLULAR_APN_USER", "") -APN_PASS = os.getenv("YARIS_CELLULAR_APN_PASS", "") -ENABLED = os.getenv("YARIS_CELLULAR_ENABLED", "") == "1" +from modules import app_gps, app_sql GPRS_BRINGUP_TIMEOUT = 30 # seconds to wait for AT+CIICR / AT+CIFSR HTTP_ACTION_TIMEOUT = 60 # seconds to wait for the +HTTPACTION URC (2G/3G can be slow) +HTTP_USERDATA_MAX_BYTES = 1024 _gprs_context_up = False @@ -48,9 +44,19 @@ class CellularError(Exception): def is_configured(): """True once the user has filled in an APN and flipped the enabled flag - in db/upload_creds.env. Off by default - nothing cellular-related runs - unless explicitly opted into.""" - return ENABLED and bool(APN) + in the Settings page or legacy environment configuration. Off by default - + nothing cellular-related runs unless explicitly opted into.""" + enabled = app_sql.get_setting( + "cellular_enabled", + "1" if os.getenv("YARIS_CELLULAR_ENABLED", "") == "1" else "0", + ) + return enabled == "1" and bool(get_apn()) + + +def get_apn(): + return app_sql.get_setting( + "cellular_apn", os.getenv("YARIS_CELLULAR_APN", "") + ) def _read_until(ser, terminators, timeout): @@ -104,7 +110,16 @@ def _ensure_gprs_context(): _at(ser, "AT+CGATT=1", timeout=10) - apn_cmd = f'AT+CSTT="{APN}","{APN_USER}","{APN_PASS}"' + apn = _validate_http_parameter(get_apn(), "APN") + apn_user = _validate_http_parameter( + os.getenv("YARIS_CELLULAR_APN_USER", ""), + "APN username", + ) + apn_password = _validate_http_parameter( + os.getenv("YARIS_CELLULAR_APN_PASS", ""), + "APN password", + ) + apn_cmd = f'AT+CSTT="{apn}","{apn_user}","{apn_password}"' _at(ser, apn_cmd, timeout=10) _at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT) @@ -116,8 +131,22 @@ def _ensure_gprs_context(): _gprs_context_up = True -def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT): - """POST a small JSON body to `url` via the modem's built-in HTTP client. +def _validate_http_parameter(value, label): + if not isinstance(value, str) or any( + char in value for char in ('"', "\r", "\n") + ): + raise CellularError(f"{label} contains unsupported characters") + return value + + +def http_post( + url, + body_bytes, + extra_headers, + content_type="application/json", + timeout=HTTP_ACTION_TIMEOUT, +): + """POST a small body to `url` via the modem's built-in HTTP client. Returns (status_code, response_body_bytes). Raises CellularError on any failure (modem not responding, no network attach, HTTP-layer failure, @@ -126,6 +155,18 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT): """ if not is_configured(): raise CellularError("cellular fallback not configured (APN/enabled flag not set)") + _validate_http_parameter(url, "URL") + _validate_http_parameter(content_type, "Content type") + for name, value in extra_headers.items(): + _validate_http_parameter(name, "Header name") + _validate_http_parameter(value, f"{name} header") + header_lines = r"\r\n".join( + f"{name}: {value}" for name, value in extra_headers.items() + ) + if header_lines: + header_lines += r"\r\n" + if len(header_lines.encode("utf-8")) > HTTP_USERDATA_MAX_BYTES: + raise CellularError("HTTP headers exceed the modem's 1024-byte limit") _ensure_gprs_context() @@ -153,10 +194,14 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT): "cellular is unavailable on this firmware. Consider a " "plain-HTTP router port-forward path for cellular use." ) - _at(ser, 'AT+HTTPPARA="CONTENT","application/json"', timeout=5) + _at(ser, f'AT+HTTPPARA="CONTENT","{content_type}"', timeout=5) - header_lines = "".join(f"{k}: {v}\r\n" for k, v in extra_headers.items()) - _at(ser, f'AT+HTTPPARA="USERDATA","{header_lines}"', timeout=5) + if header_lines: + _at( + ser, + f'AT+HTTPPARA="USERDATA","{header_lines}"', + timeout=5, + ) _at( ser, @@ -192,3 +237,13 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT): _at(ser, "AT+HTTPTERM", timeout=5) except CellularError: pass + + +def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT): + return http_post( + url, + body_bytes, + extra_headers, + content_type="application/json", + timeout=timeout, + ) diff --git a/modules/app_settings.py b/modules/app_settings.py new file mode 100644 index 0000000..0516b23 --- /dev/null +++ b/modules/app_settings.py @@ -0,0 +1,205 @@ +"""Validated configuration exposed by the local Settings page.""" +import os +import re +from urllib.parse import urlparse + +from modules import app_alerts, app_sql, app_upload, app_video + + +_APN_PATTERN = re.compile(r"^[A-Za-z0-9.-]*$") +_NUMBER_FIELDS = { + "temperature_c": ("alert_temperature_c", float, 40, 100), + "disk_percent": ("alert_disk_percent", float, 50, 100), + "tracking_backlog": ("alert_tracking_backlog", int, 1, 1_000_000), + "video_backlog": ("alert_video_backlog", int, 1, 100_000), + "gps_loss_seconds": ("alert_gps_loss_seconds", int, 10, 3_600), + "repeat_seconds": ("alert_repeat_seconds", int, 60, 604_800), +} + + +def _env_bool(name): + return os.getenv(name, "") == "1" + + +def _setting_bool(key, env_name=None, default=False): + fallback = "1" if (_env_bool(env_name) if env_name else default) else "0" + return app_sql.get_setting(key, fallback) == "1" + + +def _number_value(setting_key, converter): + default = app_alerts.SETTING_DEFAULTS[setting_key] + try: + return converter(app_sql.get_setting(setting_key, default)) + except (TypeError, ValueError): + return converter(default) + + +def get_settings(): + return { + "dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(), + "local_video_retention_days": app_video.retention_days(), + "cellular_enabled": _setting_bool( + "cellular_enabled", "YARIS_CELLULAR_ENABLED" + ), + "cellular_apn": app_sql.get_setting( + "cellular_apn", os.getenv("YARIS_CELLULAR_APN", "") + ), + "ntfy_enabled": _setting_bool("alerts_enabled"), + "ntfy_url_configured": bool( + app_sql.get_setting("alert_ntfy_url", "") + ), + "ntfy_token_configured": bool( + app_sql.get_setting("alert_ntfy_token", "") + ), + "alert_thresholds": { + field: _number_value(setting_key, converter) + for field, ( + setting_key, + converter, + _minimum, + _maximum, + ) in _NUMBER_FIELDS.items() + }, + } + + +def _boolean(body, field, errors): + value = body.get(field) + if not isinstance(value, bool): + errors[field] = "Must be true or false." + return None + return value + + +def _bounded_number(body, field, converter, minimum, maximum, errors): + value = body.get(field) + if ( + isinstance(value, bool) + or (converter is int and isinstance(value, float) and not value.is_integer()) + ): + errors[field] = f"Must be between {minimum} and {maximum}." + return None + try: + converted = converter(value) + except (TypeError, ValueError): + errors[field] = f"Must be between {minimum} and {maximum}." + return None + if converted < minimum or converted > maximum: + errors[field] = f"Must be between {minimum} and {maximum}." + return None + return converted + + +def update_settings(body): + if not isinstance(body, dict): + return {"request": "Expected a JSON object."} + + errors = {} + values = {} + for field in ( + "dashcam_upload_enabled", + "cellular_enabled", + "ntfy_enabled", + "clear_ntfy_url", + "clear_ntfy_token", + ): + values[field] = _boolean(body, field, errors) + + values["local_video_retention_days"] = _bounded_number( + body, + "local_video_retention_days", + int, + 0, + 3_650, + errors, + ) + + apn = body.get("cellular_apn") + if not isinstance(apn, str): + errors["cellular_apn"] = "Must be text." + else: + apn = apn.strip() + if len(apn) > 100 or not _APN_PATTERN.fullmatch(apn): + errors["cellular_apn"] = ( + "Use at most 100 letters, numbers, dots, or hyphens." + ) + values["cellular_apn"] = apn + if values["cellular_enabled"] and not apn: + errors["cellular_apn"] = "An APN is required when cellular is enabled." + + ntfy_url = body.get("ntfy_url") + ntfy_token = body.get("ntfy_token") + if not isinstance(ntfy_url, str): + errors["ntfy_url"] = "Must be text." + if not isinstance(ntfy_token, str): + errors["ntfy_token"] = "Must be text." + if isinstance(ntfy_url, str): + ntfy_url = ntfy_url.strip() + if len(ntfy_url) > 2_048: + errors["ntfy_url"] = "Must be at most 2048 characters." + elif ntfy_url: + parsed = urlparse(ntfy_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + errors["ntfy_url"] = "Must be a valid http(s) URL." + if isinstance(ntfy_token, str) and len(ntfy_token) > 512: + errors["ntfy_token"] = "Must be at most 512 characters." + + existing_url = app_sql.get_setting("alert_ntfy_url", "") + effective_url = existing_url + if isinstance(ntfy_url, str) and ntfy_url: + effective_url = ntfy_url + if values["clear_ntfy_url"]: + effective_url = "" + if values["ntfy_enabled"] and not effective_url: + errors["ntfy_url"] = ( + "A ntfy URL is required when notifications are enabled." + ) + + thresholds = body.get("alert_thresholds") + if not isinstance(thresholds, dict): + errors["alert_thresholds"] = "Must be an object." + else: + for field, ( + setting_key, + converter, + minimum, + maximum, + ) in _NUMBER_FIELDS.items(): + value = _bounded_number( + thresholds, + field, + converter, + minimum, + maximum, + errors, + ) + values[setting_key] = value + + if errors: + return errors + + settings = { + app_upload.DASHCAM_UPLOAD_SETTING_KEY: ( + "1" if values["dashcam_upload_enabled"] else "0" + ), + app_video.RETENTION_SETTING: str( + values["local_video_retention_days"] + ), + "cellular_enabled": "1" if values["cellular_enabled"] else "0", + "cellular_apn": apn, + "alerts_enabled": "1" if values["ntfy_enabled"] else "0", + } + for setting_key, _converter, _minimum, _maximum in _NUMBER_FIELDS.values(): + settings[setting_key] = str(values[setting_key]) + + if values["clear_ntfy_url"]: + settings["alert_ntfy_url"] = "" + elif ntfy_url: + settings["alert_ntfy_url"] = ntfy_url + if values["clear_ntfy_token"]: + settings["alert_ntfy_token"] = "" + elif ntfy_token: + settings["alert_ntfy_token"] = ntfy_token + + app_sql.set_settings(settings) + return {} diff --git a/modules/app_sql.py b/modules/app_sql.py index 912b0f5..d5ed7a0 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -405,6 +405,24 @@ def set_setting(key, value): conn.commit() +def set_settings(settings): + """Persist a validated group of settings atomically.""" + conn = get_conn() + if conn is None: + raise RuntimeError("Settings database is not available.") + with _conn_lock: + try: + conn.executemany( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + settings.items(), + ) + conn.commit() + except sqlite3.Error: + conn.rollback() + raise + + def get_alert_states(): conn = get_conn() if conn is None: diff --git a/readme.md b/readme.md index b0b8451..06a0058 100644 --- a/readme.md +++ b/readme.md @@ -124,3 +124,10 @@ phase history, verification notes, and current scope live in A temporary, removable real-vehicle compatibility test in the 2013 Yaris precedes the final low-priority Android phase; bench and synthetic verification alone are not treated as ready-to-install sign-off. Permanent installation is owner-handled and is not a roadmap phase. + +The local Web UI also includes a Settings page for safe runtime configuration: dashcam uploads, +uploaded-video retention, cellular APN fallback, optional ntfy delivery, and alert thresholds. +Notification secrets are write-only in the browser API, and network credentials that could lock +the Pi out remain SSH/file-managed. When cellular is configured, small ntfy notifications use the +SIM868 first and WiFi/Ethernet only as fallback; that modem path is implemented and mock-tested but +still needs real-SIM/carrier verification. diff --git a/static/js/dash.js b/static/js/dash.js index 85b550c..9ca6eb6 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -285,59 +285,3 @@ scanForDashcam(); refreshDashcamFiles(); // --- Remote server upload status/toggle --- - -const uploadStatusEl = document.getElementById('upload-status'); -const dashcamToggle = document.getElementById('dashcam-upload-toggle'); - -function describeUploadStatus(status) { - if (!status.configured) { - return 'Remote server not configured on this device (see db/upload_creds.env).'; - } - let text = `Pending trip-log rows to upload: ${status.pending_tracking_rows}.`; - if (status.last_success_at) { - text += ` Last successful upload: ${status.last_success_at}`; - if (status.last_network_path) { - text += ` (via ${status.last_network_path === 'cellular' ? 'cellular fallback' : 'WiFi/Ethernet'})`; - } - text += '.'; - } - if (status.cellular_fallback_configured) { - text += ' Cellular (SIM868) fallback is enabled for trip logs if WiFi/Ethernet is unreachable.'; - } - if (status.last_error) { - text += ` Last error: ${status.last_error}.`; - } - return text; -} - -function refreshUploadStatus() { - fetch('/api/upload/status') - .then(response => response.json()) - .then(status => { - uploadStatusEl.textContent = describeUploadStatus(status); - dashcamToggle.checked = status.dashcam_upload_enabled; - dashcamToggle.disabled = !status.configured; - }) - .catch(err => { - uploadStatusEl.textContent = 'Error while checking upload status: ' + err; - }); -} - -dashcamToggle.addEventListener('change', () => { - fetch('/api/upload/dashcam_toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: dashcamToggle.checked }), - }) - .then(response => response.json()) - .then(status => { - uploadStatusEl.textContent = describeUploadStatus(status); - dashcamToggle.checked = status.dashcam_upload_enabled; - }) - .catch(err => { - uploadStatusEl.textContent = 'Error while updating toggle: ' + err; - }); -}); - -refreshUploadStatus(); -setInterval(refreshUploadStatus, 15000); diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..1551b8d --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,172 @@ +const form = document.getElementById('settings-form'); +const saveButtons = Array.from(document.querySelectorAll('.settings-save')); +const message = document.getElementById('settings-message'); + +const fields = { + dashcamUpload: document.getElementById('dashcam-upload-enabled'), + retention: document.getElementById('retention-days'), + cellularEnabled: document.getElementById('cellular-enabled'), + cellularApn: document.getElementById('cellular-apn'), + ntfyEnabled: document.getElementById('ntfy-enabled'), + ntfyUrl: document.getElementById('ntfy-url'), + ntfyToken: document.getElementById('ntfy-token'), + clearNtfyUrl: document.getElementById('clear-ntfy-url'), + clearNtfyToken: document.getElementById('clear-ntfy-token'), + temperature: document.getElementById('temperature-c'), + disk: document.getElementById('disk-percent'), + trackingBacklog: document.getElementById('tracking-backlog'), + videoBacklog: document.getElementById('video-backlog'), + gpsLoss: document.getElementById('gps-loss-seconds'), + repeat: document.getElementById('repeat-seconds'), +}; + +function showMessage(text, kind) { + message.textContent = text; + message.className = `alert alert-${kind}`; + message.hidden = false; +} + +function setSaving(disabled) { + saveButtons.forEach(button => { + button.disabled = disabled; + }); +} + +function clearErrors() { + form.querySelectorAll('.is-invalid').forEach(element => element.classList.remove('is-invalid')); + form.querySelectorAll('[data-error-for]').forEach(element => { + element.textContent = ''; + }); +} + +function applyErrors(errors) { + const errorElements = { + local_video_retention_days: fields.retention, + cellular_apn: fields.cellularApn, + ntfy_url: fields.ntfyUrl, + ntfy_token: fields.ntfyToken, + temperature_c: fields.temperature, + disk_percent: fields.disk, + tracking_backlog: fields.trackingBacklog, + video_backlog: fields.videoBacklog, + gps_loss_seconds: fields.gpsLoss, + repeat_seconds: fields.repeat, + }; + Object.entries(errors).forEach(([name, text]) => { + const input = errorElements[name]; + const feedback = form.querySelector(`[data-error-for="${name}"]`); + if (input) input.classList.add('is-invalid'); + if (feedback) feedback.textContent = text; + }); +} + +function populate(settings) { + const thresholds = settings.alert_thresholds; + fields.dashcamUpload.checked = settings.dashcam_upload_enabled; + fields.retention.value = settings.local_video_retention_days; + fields.cellularEnabled.checked = settings.cellular_enabled; + fields.cellularApn.value = settings.cellular_apn; + fields.ntfyEnabled.checked = settings.ntfy_enabled; + fields.temperature.value = thresholds.temperature_c; + fields.disk.value = thresholds.disk_percent; + fields.trackingBacklog.value = thresholds.tracking_backlog; + fields.videoBacklog.value = thresholds.video_backlog; + fields.gpsLoss.value = thresholds.gps_loss_seconds; + fields.repeat.value = thresholds.repeat_seconds; + document.getElementById('ntfy-url-state').textContent = settings.ntfy_url_configured + ? 'A URL is saved. Leave blank to keep it unchanged.' + : 'No URL is saved.'; + document.getElementById('ntfy-token-state').textContent = settings.ntfy_token_configured + ? 'A token is saved. Leave blank to keep it unchanged.' + : 'No token is saved; public ntfy topics do not require one.'; + fields.ntfyUrl.value = ''; + fields.ntfyToken.value = ''; + fields.clearNtfyUrl.checked = false; + fields.clearNtfyToken.checked = false; +} + +function payload() { + return { + dashcam_upload_enabled: fields.dashcamUpload.checked, + local_video_retention_days: Number(fields.retention.value), + cellular_enabled: fields.cellularEnabled.checked, + cellular_apn: fields.cellularApn.value, + ntfy_enabled: fields.ntfyEnabled.checked, + ntfy_url: fields.ntfyUrl.value, + ntfy_token: fields.ntfyToken.value, + clear_ntfy_url: fields.clearNtfyUrl.checked, + clear_ntfy_token: fields.clearNtfyToken.checked, + alert_thresholds: { + temperature_c: Number(fields.temperature.value), + disk_percent: Number(fields.disk.value), + tracking_backlog: Number(fields.trackingBacklog.value), + video_backlog: Number(fields.videoBacklog.value), + gps_loss_seconds: Number(fields.gpsLoss.value), + repeat_seconds: Number(fields.repeat.value), + }, + }; +} + +async function loadUploadHealth() { + const element = document.getElementById('upload-health'); + try { + const response = await fetch('/api/upload/status'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const status = await response.json(); + if (!status.configured) { + element.textContent = 'Remote server upload is not configured on this Pi.'; + element.className = 'small text-muted mb-3'; + return; + } + const parts = [`${status.pending_tracking_rows} tracking row(s) pending.`]; + if (status.last_success_at) { + parts.push(`Last success: ${status.last_success_at}${status.last_network_path ? ` via ${status.last_network_path}.` : '.'}`); + } + if (status.last_error) parts.push(`Last error: ${status.last_error}`); + element.textContent = parts.join(' '); + element.className = `small mb-3 ${status.last_error ? 'text-danger' : 'text-muted'}`; + } catch (error) { + element.textContent = `Could not load upload status: ${error.message}`; + element.className = 'small text-danger mb-3'; + } +} + +async function loadSettings() { + setSaving(true); + try { + const response = await fetch('/api/settings'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + populate(await response.json()); + setSaving(false); + } catch (error) { + showMessage(`Could not load settings: ${error.message}`, 'danger'); + } +} + +form.addEventListener('submit', async event => { + event.preventDefault(); + clearErrors(); + message.hidden = true; + setSaving(true); + try { + const response = await fetch('/api/settings', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload()), + }); + const result = await response.json(); + if (!response.ok) { + applyErrors(result.fields || {}); + throw new Error(result.error || `HTTP ${response.status}`); + } + populate(result); + showMessage('Settings saved.', 'success'); + } catch (error) { + if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger'); + } finally { + setSaving(false); + } +}); + +loadSettings(); +loadUploadHealth(); diff --git a/templates/base.html b/templates/base.html index 99e1f9b..c1c40bd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -41,6 +41,11 @@ DTC +
diff --git a/templates/dash.html b/templates/dash.html index 6aea24e..51af7ee 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -97,27 +97,6 @@ ) }}
-
-
-

Remote server upload

-
-
-

Checking upload status...

-
- - -
-

- Trip/GPS log uploads always stay on. Turning this off only pauses - the (larger) dashcam video uploads - useful on a slow or metered - connection. If configured, small trip-log batches can also fall - back to the SIM868's cellular data when WiFi/Ethernet is - unreachable - dashcam video never uses cellular. -

-
-
{% endblock %} {% block scripts %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..5d2a521 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block title %}Settings - Yaris OBD{% endblock %} + +{% block content %} +
+
+

Settings

+

Configuration stored locally on this Pi.

+
+ +
+ + + +
+
+
+

Uploads and local video

+
+
+
+ + +
+

Trip/GPS uploads remain enabled. Dashcam video only uploads over WiFi/Ethernet.

+

Checking remote upload status...

+ +
+ + days +
+
Use 0 to keep uploaded local copies until manually deleted.
+
+
+
+ +
+
+

Cellular trip-log fallback

+
+
+
+ + +
+ + +
+
Used only for small trip-log batches when WiFi/Ethernet is unavailable. Dashcam video never uses cellular.
+
+
+ +
+
+

Optional ntfy notifications

+
+
+
+ + +
+ + +
+
Leave blank to keep the saved URL unchanged.
+
+ + +
+ + +
+
Leave blank to keep the saved token unchanged.
+
+ + +
+
+
+ +
+
+

Alert thresholds and repeat interval

+
+
+
+
+ +
°C
+
+
+
+ +
%
+
+
+
+ + +
+
+
+ + +
+
+
+ +
seconds
+
+
+
+ +
seconds
+
+
+
+

Local Web UI alerts remain available even when ntfy is disabled.

+
+
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tests/test_app_alerts.py b/tests/test_app_alerts.py index 68d37c5..64fa6c5 100644 --- a/tests/test_app_alerts.py +++ b/tests/test_app_alerts.py @@ -139,6 +139,122 @@ class NtfyTransportTest(unittest.TestCase): ) response.raise_for_status.assert_called_once_with() + @patch("modules.app_alerts.app_cellular.http_post") + @patch( + "modules.app_alerts.app_cellular.is_configured", + return_value=True, + ) + @patch("modules.app_alerts.requests.post") + def test_cellular_is_primary_when_configured( + self, + post, + _is_configured, + cellular_post, + ): + cellular_post.return_value = (200, b"ok") + + app_alerts.send_ntfy( + "https://ntfy.example/topic", + "secret", + "Alert title", + "Alert body", + "high", + ["warning"], + ) + + cellular_post.assert_called_once_with( + "https://ntfy.example/topic", + b"Alert body", + { + "Title": "Alert title", + "Priority": "high", + "Tags": "warning", + "Authorization": "Bearer secret", + }, + content_type="text/plain; charset=utf-8", + ) + post.assert_not_called() + + @patch("modules.app_alerts.app_cellular.http_post") + @patch( + "modules.app_alerts.app_cellular.is_configured", + return_value=True, + ) + @patch("modules.app_alerts.requests.post") + def test_cellular_http_error_falls_back_to_wifi( + self, + post, + _is_configured, + cellular_post, + ): + cellular_post.return_value = (503, b"error") + post.return_value.raise_for_status.return_value = None + + app_alerts.send_ntfy( + "https://ntfy.example/topic", + "", + "Alert title", + "Alert body", + ) + + cellular_post.assert_called_once() + post.assert_called_once() + + @patch("modules.app_alerts.app_cellular.http_post") + @patch( + "modules.app_alerts.app_cellular.is_configured", + return_value=True, + ) + @patch("modules.app_alerts.requests.post") + def test_wifi_error_is_reported_after_cellular_failure( + self, + post, + _is_configured, + cellular_post, + ): + cellular_post.side_effect = app_alerts.app_cellular.CellularError( + "modem offline" + ) + post.side_effect = requests.ReadTimeout("response timed out") + + with self.assertRaises(requests.ReadTimeout): + app_alerts.send_ntfy( + "https://ntfy.example/topic", + "", + "Alert title", + "Alert body", + ) + + cellular_post.assert_called_once() + + @patch("modules.app_alerts.app_cellular.http_post") + @patch( + "modules.app_alerts.app_cellular.is_configured", + return_value=True, + ) + @patch("modules.app_alerts.requests.post") + def test_both_transport_failures_do_not_expose_private_url( + self, + post, + _is_configured, + cellular_post, + ): + post.side_effect = requests.ConnectionError("offline") + cellular_post.return_value = (503, b"error") + + with self.assertRaises(requests.ConnectionError) as raised: + app_alerts.send_ntfy( + "https://ntfy.example/private-topic", + "", + "Alert title", + "Alert body", + ) + + self.assertEqual( + app_alerts._transport_error(raised.exception), + "Notification connection failed", + ) + def test_transport_error_does_not_expose_url(self): response = requests.Response() response.status_code = 401 diff --git a/tests/test_app_cellular.py b/tests/test_app_cellular.py new file mode 100644 index 0000000..f50bc0d --- /dev/null +++ b/tests/test_app_cellular.py @@ -0,0 +1,114 @@ +import unittest +from contextlib import nullcontext +from unittest.mock import MagicMock, patch + +from modules import app_cellular + + +class CellularHttpTest(unittest.TestCase): + def test_rejects_at_command_injection_before_modem_access(self): + with ( + patch( + "modules.app_cellular.is_configured", + return_value=True, + ), + patch("modules.app_cellular._ensure_gprs_context") as ensure, + self.assertRaises(app_cellular.CellularError), + ): + app_cellular.http_post( + 'https://example.test/"\r\nAT+RESET', + b"body", + {}, + ) + + ensure.assert_not_called() + + @patch("modules.app_cellular.http_post") + def test_json_wrapper_uses_json_content_type(self, post): + post.return_value = (200, b"ok") + + result = app_cellular.http_post_json( + "https://example.test/upload", + b"{}", + {"X-Api-Key": "key"}, + timeout=30, + ) + + self.assertEqual(result, (200, b"ok")) + post.assert_called_once_with( + "https://example.test/upload", + b"{}", + {"X-Api-Key": "key"}, + content_type="application/json", + timeout=30, + ) + + @patch("modules.app_cellular._read_until", return_value="OK") + @patch("modules.app_cellular._ensure_gprs_context") + @patch("modules.app_cellular.is_configured", return_value=True) + @patch("modules.app_cellular._at") + def test_multiple_headers_use_literal_crlf_escape_sequence( + self, + at, + _is_configured, + _ensure_context, + _read_until, + ): + serial = MagicMock() + at.side_effect = lambda _serial, command, **_kwargs: ( + "+HTTPACTION: 1,200,0" + if command == "AT+HTTPACTION=1" + else "OK" + ) + with ( + patch( + "modules.app_cellular.app_gps._serial_lock", + nullcontext(), + ), + patch( + "modules.app_cellular.app_gps._get_serial", + return_value=serial, + ), + ): + status, _body = app_cellular.http_post( + "https://example.test/topic", + b"message", + {"Title": "Alert", "Priority": "high"}, + content_type="text/plain", + ) + + self.assertEqual(status, 200) + user_data_commands = [ + call.args[1] + for call in at.call_args_list + if 'AT+HTTPPARA="USERDATA"' in call.args[1] + ] + self.assertEqual( + user_data_commands, + [ + 'AT+HTTPPARA="USERDATA","' + r'Title: Alert\r\nPriority: high\r\n"' + ], + ) + self.assertNotIn("\r", user_data_commands[0]) + self.assertNotIn("\n", user_data_commands[0]) + + @patch("modules.app_cellular._ensure_gprs_context") + @patch("modules.app_cellular.is_configured", return_value=True) + def test_rejects_headers_over_modem_limit( + self, + _is_configured, + ensure_context, + ): + with self.assertRaises(app_cellular.CellularError): + app_cellular.http_post( + "https://example.test/topic", + b"message", + {"X-Large": "x" * 1024}, + ) + + ensure_context.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py new file mode 100644 index 0000000..a8a5cc5 --- /dev/null +++ b/tests/test_app_settings.py @@ -0,0 +1,171 @@ +import os +import unittest +from unittest.mock import patch + +from modules import app_cellular, app_settings + + +def valid_payload(): + return { + "dashcam_upload_enabled": False, + "local_video_retention_days": 14, + "cellular_enabled": True, + "cellular_apn": "carrier.apn", + "ntfy_enabled": True, + "ntfy_url": "https://ntfy.example/private-topic", + "ntfy_token": "secret-token", + "clear_ntfy_url": False, + "clear_ntfy_token": False, + "alert_thresholds": { + "temperature_c": 75.5, + "disk_percent": 88, + "tracking_backlog": 500, + "video_backlog": 8, + "gps_loss_seconds": 90, + "repeat_seconds": 7200, + }, + } + + +class SettingsUpdateTest(unittest.TestCase): + def setUp(self): + self.settings = {} + + def get_setting(self, key, default=None): + return self.settings.get(key, default) + + def save_settings(self, settings): + self.settings.update(settings) + + def update(self, payload): + with ( + patch( + "modules.app_settings.app_sql.get_setting", + side_effect=self.get_setting, + ), + patch( + "modules.app_settings.app_sql.set_settings", + side_effect=self.save_settings, + ), + ): + return app_settings.update_settings(payload) + + def test_valid_settings_are_saved_together(self): + self.assertEqual(self.update(valid_payload()), {}) + + self.assertEqual(self.settings["dashcam_upload_enabled"], "0") + self.assertEqual(self.settings["local_video_retention_days"], "14") + self.assertEqual(self.settings["cellular_enabled"], "1") + self.assertEqual(self.settings["cellular_apn"], "carrier.apn") + self.assertEqual(self.settings["alerts_enabled"], "1") + self.assertEqual( + self.settings["alert_ntfy_url"], + "https://ntfy.example/private-topic", + ) + self.assertEqual(self.settings["alert_ntfy_token"], "secret-token") + self.assertEqual(self.settings["alert_temperature_c"], "75.5") + + def test_invalid_payload_does_not_save_any_settings(self): + payload = valid_payload() + payload["cellular_apn"] = "bad apn" + payload["alert_thresholds"]["repeat_seconds"] = 30 + + errors = self.update(payload) + + self.assertIn("cellular_apn", errors) + self.assertIn("repeat_seconds", errors) + self.assertEqual(self.settings, {}) + + def test_enabled_transports_require_configuration(self): + payload = valid_payload() + payload["cellular_apn"] = "" + payload["ntfy_url"] = "" + + errors = self.update(payload) + + self.assertIn("cellular_apn", errors) + self.assertIn("ntfy_url", errors) + + def test_blank_secrets_are_preserved_and_explicit_clear_removes_them(self): + self.settings.update( + { + "alert_ntfy_url": "https://ntfy.example/existing", + "alert_ntfy_token": "existing-token", + } + ) + payload = valid_payload() + payload["ntfy_url"] = "" + payload["ntfy_token"] = "" + payload["ntfy_enabled"] = False + + self.assertEqual(self.update(payload), {}) + self.assertEqual( + self.settings["alert_ntfy_url"], + "https://ntfy.example/existing", + ) + self.assertEqual(self.settings["alert_ntfy_token"], "existing-token") + + payload["clear_ntfy_url"] = True + payload["clear_ntfy_token"] = True + self.assertEqual(self.update(payload), {}) + self.assertEqual(self.settings["alert_ntfy_url"], "") + self.assertEqual(self.settings["alert_ntfy_token"], "") + + def test_fractional_integer_field_is_rejected(self): + payload = valid_payload() + payload["alert_thresholds"]["video_backlog"] = 1.5 + + errors = self.update(payload) + + self.assertIn("video_backlog", errors) + + +class SettingsReadTest(unittest.TestCase): + @patch("modules.app_settings.app_video.retention_days", return_value=30) + @patch( + "modules.app_settings.app_upload.is_dashcam_upload_enabled", + return_value=True, + ) + @patch("modules.app_settings.app_sql.get_setting") + def test_get_settings_does_not_return_ntfy_secrets( + self, + get_setting, + _upload_enabled, + _retention_days, + ): + stored = { + "cellular_enabled": "0", + "cellular_apn": "", + "alerts_enabled": "1", + "alert_ntfy_url": "https://ntfy.example/private", + "alert_ntfy_token": "secret-token", + } + get_setting.side_effect = lambda key, default=None: stored.get(key, default) + + settings = app_settings.get_settings() + + self.assertTrue(settings["ntfy_url_configured"]) + self.assertTrue(settings["ntfy_token_configured"]) + self.assertNotIn("ntfy_url", settings) + self.assertNotIn("ntfy_token", settings) + + +class CellularSettingsTest(unittest.TestCase): + @patch.dict( + os.environ, + { + "YARIS_CELLULAR_ENABLED": "0", + "YARIS_CELLULAR_APN": "legacy.apn", + }, + ) + @patch("modules.app_cellular.app_sql.get_setting") + def test_persisted_cellular_settings_apply_without_restart(self, get_setting): + settings = {"cellular_enabled": "1", "cellular_apn": "live.apn"} + get_setting.side_effect = lambda key, default=None: settings.get(key, default) + + self.assertTrue(app_cellular.is_configured()) + self.assertEqual(app_cellular.get_apn(), "live.apn") + + +if __name__ == "__main__": + unittest.main()