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
+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: