Files
yaris-obd/modules/app_settings.py
T
Mathieu MoranandCopilot 3a6f12ba9d Add local settings management
Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing.

Co-authored-by: Copilot <[email protected]>
2026-08-09 21:26:15 -04:00

206 lines
6.4 KiB
Python

"""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 {}