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
|
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.
|
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**
|
**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
|
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`,
|
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
|
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.
|
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
|
### Phase 22: Finish web UI redesign — 🚧 PLANNED
|
||||||
**Roadmap decision 2026-08-09: keep this as Phase 22 rather than moving it forward.** Phase 15
|
**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
|
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.
|
HTML/JavaScript.
|
||||||
- Support selecting a vehicle/device if more than one is registered later, while defaulting
|
- 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.
|
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
|
- 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,
|
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
|
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.
|
returns to recording afterward. Test deletion only with the disposable clip.
|
||||||
- **Alerts:** safely exercise conditions that can be simulated without damaging hardware (upload
|
- **Alerts:** safely exercise conditions that can be simulated without damaging hardware (upload
|
||||||
backlog, temporary GPS loss while OBD reports motion, and disposable threshold overrides).
|
backlog, temporary GPS loss while OBD reports motion, and disposable threshold overrides).
|
||||||
Confirm local bell transition/recovery/cooldown behavior. Do not intentionally overheat,
|
Confirm local bell transition/recovery/cooldown behavior and offline alert-state reconciliation
|
||||||
undervolt, fill storage, or trigger a real MIL. Verify ntfy only if it has been configured by then.
|
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,
|
- **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
|
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
|
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.
|
remotely.
|
||||||
- On the Phase 23 portal, verify the explicit **Remote server** identity, authenticated
|
- 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
|
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,
|
- 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
|
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.
|
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 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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -33,6 +33,19 @@ def dash():
|
|||||||
def history():
|
def history():
|
||||||
return render_template('history.html', account=account, car=car)
|
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')
|
@app.route('/api/trips')
|
||||||
def trips():
|
def trips():
|
||||||
return jsonify({"trips": app_sql.list_trips()})
|
return jsonify({"trips": app_sql.list_trips()})
|
||||||
@@ -152,9 +165,9 @@ def alert_status():
|
|||||||
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
||||||
def upload_dashcam_toggle():
|
def upload_dashcam_toggle():
|
||||||
body = request.get_json(silent=True) or {}
|
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
|
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())
|
return jsonify(app_upload.get_status())
|
||||||
|
|
||||||
# not sure if im gonna use this
|
# not sure if im gonna use this
|
||||||
|
|||||||
+41
-4
@@ -6,7 +6,15 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import requests
|
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
|
CHECK_INTERVAL = 60
|
||||||
@@ -94,9 +102,29 @@ def send_ntfy(url, token, title, message, priority="default", tags=None):
|
|||||||
headers["Tags"] = ",".join(tags)
|
headers["Tags"] = ",".join(tags)
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {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(
|
response = requests.post(
|
||||||
url,
|
url,
|
||||||
data=message.encode("utf-8"),
|
data=body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
@@ -104,6 +132,8 @@ def send_ntfy(url, token, title, message, priority="default", tags=None):
|
|||||||
|
|
||||||
|
|
||||||
def _transport_error(error):
|
def _transport_error(error):
|
||||||
|
if isinstance(error, app_cellular.CellularError):
|
||||||
|
return "Cellular notification failed"
|
||||||
if isinstance(error, requests.Timeout):
|
if isinstance(error, requests.Timeout):
|
||||||
return "Notification request timed out"
|
return "Notification request timed out"
|
||||||
if isinstance(error, requests.HTTPError):
|
if isinstance(error, requests.HTTPError):
|
||||||
@@ -398,7 +428,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy):
|
|||||||
condition["priority"],
|
condition["priority"],
|
||||||
condition["tags"],
|
condition["tags"],
|
||||||
)
|
)
|
||||||
except requests.RequestException as error:
|
except (
|
||||||
|
requests.RequestException,
|
||||||
|
app_cellular.CellularError,
|
||||||
|
) as error:
|
||||||
state["last_error"] = _transport_error(error)
|
state["last_error"] = _transport_error(error)
|
||||||
else:
|
else:
|
||||||
state["last_sent_at"] = now
|
state["last_sent_at"] = now
|
||||||
@@ -423,7 +456,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy):
|
|||||||
"default",
|
"default",
|
||||||
["white_check_mark"],
|
["white_check_mark"],
|
||||||
)
|
)
|
||||||
except requests.RequestException as error:
|
except (
|
||||||
|
requests.RequestException,
|
||||||
|
app_cellular.CellularError,
|
||||||
|
) as error:
|
||||||
state["last_error"] = _transport_error(error)
|
state["last_error"] = _transport_error(error)
|
||||||
else:
|
else:
|
||||||
state["recovery_pending"] = False
|
state["recovery_pending"] = False
|
||||||
@@ -455,6 +491,7 @@ def _loop():
|
|||||||
RuntimeError,
|
RuntimeError,
|
||||||
sqlite3.Error,
|
sqlite3.Error,
|
||||||
requests.RequestException,
|
requests.RequestException,
|
||||||
|
app_cellular.CellularError,
|
||||||
) as error:
|
) as error:
|
||||||
_last_run_error = str(error)
|
_last_run_error = str(error)
|
||||||
print("Alert check failed:", 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 os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from modules import app_gps
|
from modules import app_gps, app_sql
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
GPRS_BRINGUP_TIMEOUT = 30 # seconds to wait for AT+CIICR / AT+CIFSR
|
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_ACTION_TIMEOUT = 60 # seconds to wait for the +HTTPACTION URC (2G/3G can be slow)
|
||||||
|
HTTP_USERDATA_MAX_BYTES = 1024
|
||||||
|
|
||||||
_gprs_context_up = False
|
_gprs_context_up = False
|
||||||
|
|
||||||
@@ -48,9 +44,19 @@ class CellularError(Exception):
|
|||||||
|
|
||||||
def is_configured():
|
def is_configured():
|
||||||
"""True once the user has filled in an APN and flipped the enabled flag
|
"""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
|
in the Settings page or legacy environment configuration. Off by default -
|
||||||
unless explicitly opted into."""
|
nothing cellular-related runs unless explicitly opted into."""
|
||||||
return ENABLED and bool(APN)
|
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):
|
def _read_until(ser, terminators, timeout):
|
||||||
@@ -104,7 +110,16 @@ def _ensure_gprs_context():
|
|||||||
|
|
||||||
_at(ser, "AT+CGATT=1", timeout=10)
|
_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, apn_cmd, timeout=10)
|
||||||
|
|
||||||
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
|
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
|
||||||
@@ -116,8 +131,22 @@ def _ensure_gprs_context():
|
|||||||
_gprs_context_up = True
|
_gprs_context_up = True
|
||||||
|
|
||||||
|
|
||||||
def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
def _validate_http_parameter(value, label):
|
||||||
"""POST a small JSON body to `url` via the modem's built-in HTTP client.
|
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
|
Returns (status_code, response_body_bytes). Raises CellularError on any
|
||||||
failure (modem not responding, no network attach, HTTP-layer failure,
|
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():
|
if not is_configured():
|
||||||
raise CellularError("cellular fallback not configured (APN/enabled flag not set)")
|
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()
|
_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 "
|
"cellular is unavailable on this firmware. Consider a "
|
||||||
"plain-HTTP router port-forward path for cellular use."
|
"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())
|
if header_lines:
|
||||||
_at(ser, f'AT+HTTPPARA="USERDATA","{header_lines}"', timeout=5)
|
_at(
|
||||||
|
ser,
|
||||||
|
f'AT+HTTPPARA="USERDATA","{header_lines}"',
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
_at(
|
_at(
|
||||||
ser,
|
ser,
|
||||||
@@ -192,3 +237,13 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
|||||||
_at(ser, "AT+HTTPTERM", timeout=5)
|
_at(ser, "AT+HTTPTERM", timeout=5)
|
||||||
except CellularError:
|
except CellularError:
|
||||||
pass
|
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()
|
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():
|
def get_alert_states():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if conn is None:
|
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
|
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
|
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.
|
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();
|
refreshDashcamFiles();
|
||||||
|
|
||||||
// --- Remote server upload status/toggle ---
|
// --- 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>
|
<i class="fas fa-exclamation-triangle"></i><span>DTC</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<div class="text-center d-none d-md-inline">
|
<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>
|
<button class="btn rounded-circle border-0" id="sidebarToggle" type="button" aria-label="Toggle sidebar"></button>
|
||||||
|
|||||||
@@ -97,27 +97,6 @@
|
|||||||
) }}
|
) }}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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()
|
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):
|
def test_transport_error_does_not_expose_url(self):
|
||||||
response = requests.Response()
|
response = requests.Response()
|
||||||
response.status_code = 401
|
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