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
+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,
)