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:
Mathieu Moran
2026-08-09 21:26:15 -04:00
co-authored by Copilot
parent b77ed838bd
commit 3a6f12ba9d
15 changed files with 1118 additions and 103 deletions
+51 -4
View File
@@ -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.
+16 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+205
View File
@@ -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 {}
+18
View File
@@ -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:
+7
View File
@@ -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.
-56
View File
@@ -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);
+172
View File
@@ -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();
+5
View File
@@ -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>
-21
View File
@@ -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 %}
+132
View File
@@ -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 %}
+116
View File
@@ -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
+114
View File
@@ -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()
+171
View File
@@ -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()