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:
+41
-4
@@ -6,7 +6,15 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from modules import app_dash, app_gps, app_health, app_obd, app_sql, app_video
|
||||
from modules import (
|
||||
app_cellular,
|
||||
app_dash,
|
||||
app_gps,
|
||||
app_health,
|
||||
app_obd,
|
||||
app_sql,
|
||||
app_video,
|
||||
)
|
||||
|
||||
|
||||
CHECK_INTERVAL = 60
|
||||
@@ -94,9 +102,29 @@ def send_ntfy(url, token, title, message, priority="default", tags=None):
|
||||
headers["Tags"] = ",".join(tags)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = message.encode("utf-8")
|
||||
if app_cellular.is_configured():
|
||||
cellular_failure = None
|
||||
try:
|
||||
status_code, _response_body = app_cellular.http_post(
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
if 200 <= status_code < 300:
|
||||
return
|
||||
cellular_failure = f"HTTP {status_code}"
|
||||
except app_cellular.CellularError:
|
||||
cellular_failure = "modem error"
|
||||
print(
|
||||
f"Cellular notification failed ({cellular_failure}); "
|
||||
"trying WiFi/Ethernet."
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=message.encode("utf-8"),
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
@@ -104,6 +132,8 @@ def send_ntfy(url, token, title, message, priority="default", tags=None):
|
||||
|
||||
|
||||
def _transport_error(error):
|
||||
if isinstance(error, app_cellular.CellularError):
|
||||
return "Cellular notification failed"
|
||||
if isinstance(error, requests.Timeout):
|
||||
return "Notification request timed out"
|
||||
if isinstance(error, requests.HTTPError):
|
||||
@@ -398,7 +428,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy):
|
||||
condition["priority"],
|
||||
condition["tags"],
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
except (
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
state["last_error"] = _transport_error(error)
|
||||
else:
|
||||
state["last_sent_at"] = now
|
||||
@@ -423,7 +456,10 @@ def process_conditions(conditions, config, now=None, sender=send_ntfy):
|
||||
"default",
|
||||
["white_check_mark"],
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
except (
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
state["last_error"] = _transport_error(error)
|
||||
else:
|
||||
state["recovery_pending"] = False
|
||||
@@ -455,6 +491,7 @@ def _loop():
|
||||
RuntimeError,
|
||||
sqlite3.Error,
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
_last_run_error = str(error)
|
||||
print("Alert check failed:", error)
|
||||
|
||||
+70
-15
@@ -28,15 +28,11 @@ AT interface has a small internal buffer unsuited to multi-MB bodies.
|
||||
import os
|
||||
import time
|
||||
|
||||
from modules import app_gps
|
||||
|
||||
APN = os.getenv("YARIS_CELLULAR_APN", "")
|
||||
APN_USER = os.getenv("YARIS_CELLULAR_APN_USER", "")
|
||||
APN_PASS = os.getenv("YARIS_CELLULAR_APN_PASS", "")
|
||||
ENABLED = os.getenv("YARIS_CELLULAR_ENABLED", "") == "1"
|
||||
from modules import app_gps, app_sql
|
||||
|
||||
GPRS_BRINGUP_TIMEOUT = 30 # seconds to wait for AT+CIICR / AT+CIFSR
|
||||
HTTP_ACTION_TIMEOUT = 60 # seconds to wait for the +HTTPACTION URC (2G/3G can be slow)
|
||||
HTTP_USERDATA_MAX_BYTES = 1024
|
||||
|
||||
_gprs_context_up = False
|
||||
|
||||
@@ -48,9 +44,19 @@ class CellularError(Exception):
|
||||
|
||||
def is_configured():
|
||||
"""True once the user has filled in an APN and flipped the enabled flag
|
||||
in db/upload_creds.env. Off by default - nothing cellular-related runs
|
||||
unless explicitly opted into."""
|
||||
return ENABLED and bool(APN)
|
||||
in the Settings page or legacy environment configuration. Off by default -
|
||||
nothing cellular-related runs unless explicitly opted into."""
|
||||
enabled = app_sql.get_setting(
|
||||
"cellular_enabled",
|
||||
"1" if os.getenv("YARIS_CELLULAR_ENABLED", "") == "1" else "0",
|
||||
)
|
||||
return enabled == "1" and bool(get_apn())
|
||||
|
||||
|
||||
def get_apn():
|
||||
return app_sql.get_setting(
|
||||
"cellular_apn", os.getenv("YARIS_CELLULAR_APN", "")
|
||||
)
|
||||
|
||||
|
||||
def _read_until(ser, terminators, timeout):
|
||||
@@ -104,7 +110,16 @@ def _ensure_gprs_context():
|
||||
|
||||
_at(ser, "AT+CGATT=1", timeout=10)
|
||||
|
||||
apn_cmd = f'AT+CSTT="{APN}","{APN_USER}","{APN_PASS}"'
|
||||
apn = _validate_http_parameter(get_apn(), "APN")
|
||||
apn_user = _validate_http_parameter(
|
||||
os.getenv("YARIS_CELLULAR_APN_USER", ""),
|
||||
"APN username",
|
||||
)
|
||||
apn_password = _validate_http_parameter(
|
||||
os.getenv("YARIS_CELLULAR_APN_PASS", ""),
|
||||
"APN password",
|
||||
)
|
||||
apn_cmd = f'AT+CSTT="{apn}","{apn_user}","{apn_password}"'
|
||||
_at(ser, apn_cmd, timeout=10)
|
||||
|
||||
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
|
||||
@@ -116,8 +131,22 @@ def _ensure_gprs_context():
|
||||
_gprs_context_up = True
|
||||
|
||||
|
||||
def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
"""POST a small JSON body to `url` via the modem's built-in HTTP client.
|
||||
def _validate_http_parameter(value, label):
|
||||
if not isinstance(value, str) or any(
|
||||
char in value for char in ('"', "\r", "\n")
|
||||
):
|
||||
raise CellularError(f"{label} contains unsupported characters")
|
||||
return value
|
||||
|
||||
|
||||
def http_post(
|
||||
url,
|
||||
body_bytes,
|
||||
extra_headers,
|
||||
content_type="application/json",
|
||||
timeout=HTTP_ACTION_TIMEOUT,
|
||||
):
|
||||
"""POST a small body to `url` via the modem's built-in HTTP client.
|
||||
|
||||
Returns (status_code, response_body_bytes). Raises CellularError on any
|
||||
failure (modem not responding, no network attach, HTTP-layer failure,
|
||||
@@ -126,6 +155,18 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
"""
|
||||
if not is_configured():
|
||||
raise CellularError("cellular fallback not configured (APN/enabled flag not set)")
|
||||
_validate_http_parameter(url, "URL")
|
||||
_validate_http_parameter(content_type, "Content type")
|
||||
for name, value in extra_headers.items():
|
||||
_validate_http_parameter(name, "Header name")
|
||||
_validate_http_parameter(value, f"{name} header")
|
||||
header_lines = r"\r\n".join(
|
||||
f"{name}: {value}" for name, value in extra_headers.items()
|
||||
)
|
||||
if header_lines:
|
||||
header_lines += r"\r\n"
|
||||
if len(header_lines.encode("utf-8")) > HTTP_USERDATA_MAX_BYTES:
|
||||
raise CellularError("HTTP headers exceed the modem's 1024-byte limit")
|
||||
|
||||
_ensure_gprs_context()
|
||||
|
||||
@@ -153,10 +194,14 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
"cellular is unavailable on this firmware. Consider a "
|
||||
"plain-HTTP router port-forward path for cellular use."
|
||||
)
|
||||
_at(ser, 'AT+HTTPPARA="CONTENT","application/json"', timeout=5)
|
||||
_at(ser, f'AT+HTTPPARA="CONTENT","{content_type}"', timeout=5)
|
||||
|
||||
header_lines = "".join(f"{k}: {v}\r\n" for k, v in extra_headers.items())
|
||||
_at(ser, f'AT+HTTPPARA="USERDATA","{header_lines}"', timeout=5)
|
||||
if header_lines:
|
||||
_at(
|
||||
ser,
|
||||
f'AT+HTTPPARA="USERDATA","{header_lines}"',
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
_at(
|
||||
ser,
|
||||
@@ -192,3 +237,13 @@ def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
_at(ser, "AT+HTTPTERM", timeout=5)
|
||||
except CellularError:
|
||||
pass
|
||||
|
||||
|
||||
def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
return http_post(
|
||||
url,
|
||||
body_bytes,
|
||||
extra_headers,
|
||||
content_type="application/json",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Validated configuration exposed by the local Settings page."""
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from modules import app_alerts, app_sql, app_upload, app_video
|
||||
|
||||
|
||||
_APN_PATTERN = re.compile(r"^[A-Za-z0-9.-]*$")
|
||||
_NUMBER_FIELDS = {
|
||||
"temperature_c": ("alert_temperature_c", float, 40, 100),
|
||||
"disk_percent": ("alert_disk_percent", float, 50, 100),
|
||||
"tracking_backlog": ("alert_tracking_backlog", int, 1, 1_000_000),
|
||||
"video_backlog": ("alert_video_backlog", int, 1, 100_000),
|
||||
"gps_loss_seconds": ("alert_gps_loss_seconds", int, 10, 3_600),
|
||||
"repeat_seconds": ("alert_repeat_seconds", int, 60, 604_800),
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name):
|
||||
return os.getenv(name, "") == "1"
|
||||
|
||||
|
||||
def _setting_bool(key, env_name=None, default=False):
|
||||
fallback = "1" if (_env_bool(env_name) if env_name else default) else "0"
|
||||
return app_sql.get_setting(key, fallback) == "1"
|
||||
|
||||
|
||||
def _number_value(setting_key, converter):
|
||||
default = app_alerts.SETTING_DEFAULTS[setting_key]
|
||||
try:
|
||||
return converter(app_sql.get_setting(setting_key, default))
|
||||
except (TypeError, ValueError):
|
||||
return converter(default)
|
||||
|
||||
|
||||
def get_settings():
|
||||
return {
|
||||
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
|
||||
"local_video_retention_days": app_video.retention_days(),
|
||||
"cellular_enabled": _setting_bool(
|
||||
"cellular_enabled", "YARIS_CELLULAR_ENABLED"
|
||||
),
|
||||
"cellular_apn": app_sql.get_setting(
|
||||
"cellular_apn", os.getenv("YARIS_CELLULAR_APN", "")
|
||||
),
|
||||
"ntfy_enabled": _setting_bool("alerts_enabled"),
|
||||
"ntfy_url_configured": bool(
|
||||
app_sql.get_setting("alert_ntfy_url", "")
|
||||
),
|
||||
"ntfy_token_configured": bool(
|
||||
app_sql.get_setting("alert_ntfy_token", "")
|
||||
),
|
||||
"alert_thresholds": {
|
||||
field: _number_value(setting_key, converter)
|
||||
for field, (
|
||||
setting_key,
|
||||
converter,
|
||||
_minimum,
|
||||
_maximum,
|
||||
) in _NUMBER_FIELDS.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _boolean(body, field, errors):
|
||||
value = body.get(field)
|
||||
if not isinstance(value, bool):
|
||||
errors[field] = "Must be true or false."
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_number(body, field, converter, minimum, maximum, errors):
|
||||
value = body.get(field)
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or (converter is int and isinstance(value, float) and not value.is_integer())
|
||||
):
|
||||
errors[field] = f"Must be between {minimum} and {maximum}."
|
||||
return None
|
||||
try:
|
||||
converted = converter(value)
|
||||
except (TypeError, ValueError):
|
||||
errors[field] = f"Must be between {minimum} and {maximum}."
|
||||
return None
|
||||
if converted < minimum or converted > maximum:
|
||||
errors[field] = f"Must be between {minimum} and {maximum}."
|
||||
return None
|
||||
return converted
|
||||
|
||||
|
||||
def update_settings(body):
|
||||
if not isinstance(body, dict):
|
||||
return {"request": "Expected a JSON object."}
|
||||
|
||||
errors = {}
|
||||
values = {}
|
||||
for field in (
|
||||
"dashcam_upload_enabled",
|
||||
"cellular_enabled",
|
||||
"ntfy_enabled",
|
||||
"clear_ntfy_url",
|
||||
"clear_ntfy_token",
|
||||
):
|
||||
values[field] = _boolean(body, field, errors)
|
||||
|
||||
values["local_video_retention_days"] = _bounded_number(
|
||||
body,
|
||||
"local_video_retention_days",
|
||||
int,
|
||||
0,
|
||||
3_650,
|
||||
errors,
|
||||
)
|
||||
|
||||
apn = body.get("cellular_apn")
|
||||
if not isinstance(apn, str):
|
||||
errors["cellular_apn"] = "Must be text."
|
||||
else:
|
||||
apn = apn.strip()
|
||||
if len(apn) > 100 or not _APN_PATTERN.fullmatch(apn):
|
||||
errors["cellular_apn"] = (
|
||||
"Use at most 100 letters, numbers, dots, or hyphens."
|
||||
)
|
||||
values["cellular_apn"] = apn
|
||||
if values["cellular_enabled"] and not apn:
|
||||
errors["cellular_apn"] = "An APN is required when cellular is enabled."
|
||||
|
||||
ntfy_url = body.get("ntfy_url")
|
||||
ntfy_token = body.get("ntfy_token")
|
||||
if not isinstance(ntfy_url, str):
|
||||
errors["ntfy_url"] = "Must be text."
|
||||
if not isinstance(ntfy_token, str):
|
||||
errors["ntfy_token"] = "Must be text."
|
||||
if isinstance(ntfy_url, str):
|
||||
ntfy_url = ntfy_url.strip()
|
||||
if len(ntfy_url) > 2_048:
|
||||
errors["ntfy_url"] = "Must be at most 2048 characters."
|
||||
elif ntfy_url:
|
||||
parsed = urlparse(ntfy_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
errors["ntfy_url"] = "Must be a valid http(s) URL."
|
||||
if isinstance(ntfy_token, str) and len(ntfy_token) > 512:
|
||||
errors["ntfy_token"] = "Must be at most 512 characters."
|
||||
|
||||
existing_url = app_sql.get_setting("alert_ntfy_url", "")
|
||||
effective_url = existing_url
|
||||
if isinstance(ntfy_url, str) and ntfy_url:
|
||||
effective_url = ntfy_url
|
||||
if values["clear_ntfy_url"]:
|
||||
effective_url = ""
|
||||
if values["ntfy_enabled"] and not effective_url:
|
||||
errors["ntfy_url"] = (
|
||||
"A ntfy URL is required when notifications are enabled."
|
||||
)
|
||||
|
||||
thresholds = body.get("alert_thresholds")
|
||||
if not isinstance(thresholds, dict):
|
||||
errors["alert_thresholds"] = "Must be an object."
|
||||
else:
|
||||
for field, (
|
||||
setting_key,
|
||||
converter,
|
||||
minimum,
|
||||
maximum,
|
||||
) in _NUMBER_FIELDS.items():
|
||||
value = _bounded_number(
|
||||
thresholds,
|
||||
field,
|
||||
converter,
|
||||
minimum,
|
||||
maximum,
|
||||
errors,
|
||||
)
|
||||
values[setting_key] = value
|
||||
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
settings = {
|
||||
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
|
||||
"1" if values["dashcam_upload_enabled"] else "0"
|
||||
),
|
||||
app_video.RETENTION_SETTING: str(
|
||||
values["local_video_retention_days"]
|
||||
),
|
||||
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
|
||||
"cellular_apn": apn,
|
||||
"alerts_enabled": "1" if values["ntfy_enabled"] else "0",
|
||||
}
|
||||
for setting_key, _converter, _minimum, _maximum in _NUMBER_FIELDS.values():
|
||||
settings[setting_key] = str(values[setting_key])
|
||||
|
||||
if values["clear_ntfy_url"]:
|
||||
settings["alert_ntfy_url"] = ""
|
||||
elif ntfy_url:
|
||||
settings["alert_ntfy_url"] = ntfy_url
|
||||
if values["clear_ntfy_token"]:
|
||||
settings["alert_ntfy_token"] = ""
|
||||
elif ntfy_token:
|
||||
settings["alert_ntfy_token"] = ntfy_token
|
||||
|
||||
app_sql.set_settings(settings)
|
||||
return {}
|
||||
@@ -405,6 +405,24 @@ def set_setting(key, value):
|
||||
conn.commit()
|
||||
|
||||
|
||||
def set_settings(settings):
|
||||
"""Persist a validated group of settings atomically."""
|
||||
conn = get_conn()
|
||||
if conn is None:
|
||||
raise RuntimeError("Settings database is not available.")
|
||||
with _conn_lock:
|
||||
try:
|
||||
conn.executemany(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
settings.items(),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def get_alert_states():
|
||||
conn = get_conn()
|
||||
if conn is None:
|
||||
|
||||
Reference in New Issue
Block a user