Add local settings management
Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
-4
@@ -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)
|
||||
|
||||
+70
-15
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -41,6 +41,11 @@
|
||||
<i class="fas fa-exclamation-triangle"></i><span>DTC</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if request.endpoint == 'settings' %} active{% endif %}" href="{{ url_for('settings') }}">
|
||||
<i class="fas fa-cog"></i><span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="text-center d-none d-md-inline">
|
||||
<button class="btn rounded-circle border-0" id="sidebarToggle" type="button" aria-label="Toggle sidebar"></button>
|
||||
|
||||
@@ -97,27 +97,6 @@
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Remote server upload</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p id="upload-status">Checking upload status...</p>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-toggle" disabled>
|
||||
<label class="form-check-label" for="dashcam-upload-toggle">
|
||||
Upload dashcam videos to remote server
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - Yaris OBD{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
|
||||
<div>
|
||||
<h3 class="text-dark mb-1">Settings</h3>
|
||||
<p class="text-muted mb-0">Configuration stored locally on this Pi.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary d-none d-md-inline-block settings-save" type="submit" form="settings-form" disabled>
|
||||
<i class="fas fa-save me-1"></i>Save settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="settings-message" class="alert" role="status" hidden></div>
|
||||
|
||||
<form id="settings-form">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Uploads and local video</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-enabled">
|
||||
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to the remote server</label>
|
||||
</div>
|
||||
<p class="text-muted small">Trip/GPS uploads remain enabled. Dashcam video only uploads over WiFi/Ethernet.</p>
|
||||
<p id="upload-health" class="small mb-3">Checking remote upload status...</p>
|
||||
<label class="form-label" for="retention-days">Delete uploaded local videos after</label>
|
||||
<div class="input-group settings-number">
|
||||
<input class="form-control" id="retention-days" type="number" min="0" max="3650" step="1" required>
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<div class="form-text">Use 0 to keep uploaded local copies until manually deleted.</div>
|
||||
<div class="invalid-feedback" data-error-for="local_video_retention_days"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Cellular trip-log fallback</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="cellular-enabled">
|
||||
<label class="form-check-label" for="cellular-enabled">Enable SIM868 cellular fallback</label>
|
||||
</div>
|
||||
<label class="form-label" for="cellular-apn">Carrier APN</label>
|
||||
<input class="form-control" id="cellular-apn" maxlength="100" autocomplete="off" placeholder="example.apn">
|
||||
<div class="invalid-feedback" data-error-for="cellular_apn"></div>
|
||||
<div class="form-text">Used only for small trip-log batches when WiFi/Ethernet is unavailable. Dashcam video never uses cellular.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Optional ntfy notifications</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="ntfy-enabled">
|
||||
<label class="form-check-label" for="ntfy-enabled">Send alerts through ntfy</label>
|
||||
</div>
|
||||
<label class="form-label" for="ntfy-url">ntfy topic URL</label>
|
||||
<input class="form-control" id="ntfy-url" type="url" maxlength="2048" autocomplete="off" placeholder="https://ntfy.sh/private-topic">
|
||||
<div class="invalid-feedback" data-error-for="ntfy_url"></div>
|
||||
<div class="form-text" id="ntfy-url-state">Leave blank to keep the saved URL unchanged.</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="clear-ntfy-url">
|
||||
<label class="form-check-label" for="clear-ntfy-url">Remove saved ntfy URL</label>
|
||||
</div>
|
||||
<label class="form-label mt-3" for="ntfy-token">Access token</label>
|
||||
<input class="form-control" id="ntfy-token" type="password" maxlength="512" autocomplete="new-password" placeholder="Optional">
|
||||
<div class="invalid-feedback" data-error-for="ntfy_token"></div>
|
||||
<div class="form-text" id="ntfy-token-state">Leave blank to keep the saved token unchanged.</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="clear-ntfy-token">
|
||||
<label class="form-check-label" for="clear-ntfy-token">Remove saved ntfy token</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<p class="text-primary m-0 fw-bold">Alert thresholds and repeat interval</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="temperature-c">Pi temperature</label>
|
||||
<div class="input-group"><input class="form-control" id="temperature-c" type="number" min="40" max="100" step="0.1" required><span class="input-group-text">°C</span></div>
|
||||
<div class="invalid-feedback" data-error-for="temperature_c"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="disk-percent">Pi/dashcam disk usage</label>
|
||||
<div class="input-group"><input class="form-control" id="disk-percent" type="number" min="50" max="100" step="0.1" required><span class="input-group-text">%</span></div>
|
||||
<div class="invalid-feedback" data-error-for="disk_percent"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="tracking-backlog">Pending tracking rows</label>
|
||||
<input class="form-control" id="tracking-backlog" type="number" min="1" max="1000000" step="1" required>
|
||||
<div class="invalid-feedback" data-error-for="tracking_backlog"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="video-backlog">Pending videos</label>
|
||||
<input class="form-control" id="video-backlog" type="number" min="1" max="100000" step="1" required>
|
||||
<div class="invalid-feedback" data-error-for="video_backlog"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="gps-loss-seconds">GPS loss while moving</label>
|
||||
<div class="input-group"><input class="form-control" id="gps-loss-seconds" type="number" min="10" max="3600" step="1" required><span class="input-group-text">seconds</span></div>
|
||||
<div class="invalid-feedback" data-error-for="gps_loss_seconds"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="repeat-seconds">Repeat active notifications</label>
|
||||
<div class="input-group"><input class="form-control" id="repeat-seconds" type="number" min="60" max="604800" step="1" required><span class="input-group-text">seconds</span></div>
|
||||
<div class="invalid-feedback" data-error-for="repeat_seconds"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-3 mb-0">Local Web UI alerts remain available even when ntfy is disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 d-md-none settings-save mb-4" type="submit" disabled>
|
||||
<i class="fas fa-save me-1"></i>Save settings
|
||||
</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user