Add SIM868 cellular fallback for tracking uploads (untested, no SIM card)

modules/app_cellular.py: uses the SIM868's built-in AT-command HTTP(S)
client (AT+HTTPINIT/HTTPPARA/HTTPACTION/HTTPREAD) instead of a real
ppp0 interface, since the module's GNSS (polled continuously by
app_gps.py) and its GSM/GPRS radio share the same physical UART
(/dev/serial0) - a pppd/chat dial-up would take over that port
entirely and starve GNSS polling. Staying in AT-command mode lets
cellular HTTP calls interleave safely through the same serial lock.

Only carries small JSON tracking batches, never dashcam video -
cellular is far too slow/limited for that, and the modem's HTTP AT
interface has too small an internal buffer anyway. Dashcam uploads
remain WiFi/Ethernet-only, unchanged.

modules/app_upload.py: tracking-batch upload always tries WiFi/
Ethernet first; only on a connection-level failure (not an HTTP error
status, which means the server was reached) does it retry over
cellular. Exposes last_network_path/cellular_fallback_configured in
/api/upload/status; UI shows which path the last batch used.

Feature-flagged off by default via new YARIS_CELLULAR_ENABLED/
YARIS_CELLULAR_APN/_APN_USER/_APN_PASS keys in db/upload_creds.env
(all blank by default) - has zero effect on the existing WiFi upload
path unless explicitly configured. Regression-tested against the real
deployed server that normal WiFi uploads are unaffected while
cellular stays unconfigured.

*** UNTESTED against real cellular hardware - no SIM card was
*** available. See docstrings in app_cellular.py and PLAN.md for
*** exactly what needs validation once a SIM card is available.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-08 20:34:53 -04:00
co-authored by Copilot
parent cea62d8c10
commit 6d61b70a06
6 changed files with 311 additions and 16 deletions
+30 -3
View File
@@ -353,10 +353,37 @@ dedupe + path-traversal rejection, bad-API-key rejection, toggle-off correctly p
dashcam uploads) and on the real Pi (its actual ~1800-row pre-feature backlog drained and dashcam uploads) and on the real Pi (its actual ~1800-row pre-feature backlog drained and
confirmed matching row counts on the server via direct psql query, live upload status visible on confirmed matching row counts on the server via direct psql query, live upload status visible on
`/dash`). Committed+pushed (`4ff99f0`, `c4db1ab`, `8dc304e`), deployed on both unraid and the Pi. `/dash`). Committed+pushed (`4ff99f0`, `c4db1ab`, `8dc304e`), deployed on both unraid and the Pi.
Exposed externally at `https://yaris.ceaf.dev` via the user's Cloudflare Tunnel (public hostname
added in the Cloudflare dashboard, pointing at `192.168.10.10:8098`).
Not attempted (future, no action needed unless requested): SIM868 GSM/GPRS cellular fallback ### SIM868 cellular fallback for tracking uploads — built, UNTESTED (no SIM card available)
uplink for uploads when no network path to the server is available at all (today uploads need `modules/app_cellular.py` gives the tracking-upload path a fallback over the SIM868's cellular
the Pi to reach `yaris.ceaf.dev`, e.g. via home WiFi) — remains a possible future enhancement. data connection when WiFi/Ethernet is unreachable. Design notes:
- Uses the SIM868's built-in AT-command HTTP(S) client (`AT+HTTPINIT`/`HTTPPARA`/`HTTPACTION`/
`HTTPREAD`) rather than a real `ppp0` network interface. Reason: the SIM868's GNSS (polled
continuously by `modules/app_gps.py`) and its GSM/GPRS radio share the *same* physical UART
(`/dev/serial0`) — a classic `pppd`/`chat` dial-up would put that port into raw PPP framing for
as long as the link is up, starving the GNSS polling thread. Staying in AT-command mode lets
cellular HTTP calls interleave safely through the same serial lock
(`modules.app_gps._serial_lock`) — each exchange just briefly delays the next GPS poll.
- Only carries small JSON tracking batches, **never** dashcam video — cellular data is far too
slow/limited for multi-MB video files, and the modem's HTTP AT interface has a small internal
buffer unsuited to them anyway. Dashcam uploads remain WiFi/Ethernet-only, unchanged.
- `modules/app_upload.py`'s tracking-batch upload always tries WiFi/Ethernet (`requests`) first;
only on a connection-level failure (no route/DNS/timeout — as opposed to an HTTP error status,
which means the server WAS reached) does it retry the same batch over cellular.
- Feature-flagged off by default: `YARIS_CELLULAR_ENABLED`/`YARIS_CELLULAR_APN`/
`YARIS_CELLULAR_APN_USER`/`YARIS_CELLULAR_APN_PASS` added to `db/upload_creds.env`, all blank
by default so this has zero effect on the already-verified WiFi upload path until the user
fills in an APN once they get a SIM card. Regression-tested (against the real server) that
normal WiFi uploads are completely unaffected while cellular is unconfigured.
- `/dash` upload-status card now shows which path (`wifi`/`cellular`) the last successful batch
used, and whether cellular fallback is configured.
- **Needs validation once a SIM card is available** — exact AT command dialect/timing (SIMCOM
firmware revisions vary slightly), and whether `AT+HTTPSSL` works for the HTTPS
`yaris.ceaf.dev` endpoint on this specific module's firmware (if not, a documented fallback is
a plain-HTTP router port-forward path, avoiding the need for the modem's TLS stack to validate
a cert chain).
## Working Notes / Environment Gotchas ## Working Notes / Environment Gotchas
+14
View File
@@ -10,3 +10,17 @@
YARIS_SERVER_URL = "" YARIS_SERVER_URL = ""
YARIS_DEVICE_NAME = "" YARIS_DEVICE_NAME = ""
YARIS_API_KEY = "" YARIS_API_KEY = ""
##
## Optional SIM868 cellular fallback for trip/GPS-log uploads only (NOT
## dashcam video - too slow/limited over GPRS/3G). Only takes effect when a
## WiFi/Ethernet upload attempt fails outright (no route/DNS/timeout).
## UNTESTED - no SIM card was available during development, see
## modules/app_cellular.py. Leave YARIS_CELLULAR_ENABLED blank to keep this
## fully disabled (default) with zero effect on normal WiFi uploads.
##
YARIS_CELLULAR_ENABLED = ""
YARIS_CELLULAR_APN = ""
YARIS_CELLULAR_APN_USER = ""
YARIS_CELLULAR_APN_PASS = ""
+194
View File
@@ -0,0 +1,194 @@
"""SIM868 GSM/GPRS cellular fallback for uploading trip/GPS tracking batches
when there is no WiFi/Ethernet path to the remote server.
*** UNTESTED - no SIM card was available to validate this against a real
*** cellular network. Written defensively (every AT exchange is wrapped so a
*** failure just raises CellularError, never crashes the app) but the exact
*** AT command dialect/timing may need small tweaks once a SIM is inserted
*** and this can actually be exercised. See "Verifying on real hardware"
*** below and the Phase 9 notes in PLAN.md.
Why AT-command HTTP instead of a real ppp0 network interface: the SIM868's
GNSS (used by modules/app_gps.py) and its GSM/GPRS radio are both driven over
the *same* physical UART (/dev/serial0). A classic pppd/chat dial-up would put
that UART into raw PPP framing for as long as the link is up, which would
starve/conflict with the GNSS AT+CGNSINF polling already running continuously
in app_gps.py. SIMCOM modules instead expose a built-in "HTTP(S) Application"
AT command set (AT+HTTPINIT / AT+HTTPPARA / AT+HTTPACTION / AT+HTTPREAD) that
stays in normal AT command mode the whole time, so it can safely interleave
with GNSS polling through the same serial lock (modules.app_gps._serial_lock) -
each exchange just briefly delays the next GPS poll, it never takes over the
port.
This only carries small JSON tracking-batch payloads - dashcam video uploads
remain WiFi/Ethernet-only (see modules/app_upload.py), both because cellular
data is far too slow/limited for video files and because the modem's HTTP
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"
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)
_gprs_context_up = False
class CellularError(Exception):
"""Raised for any cellular-modem/AT-command failure - always caught by
the caller (modules/app_upload.py), never allowed to crash the app."""
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)
def _read_until(ser, terminators, timeout):
"""Read from the modem until one of the given substrings shows up in the
accumulated buffer, or timeout. Returns the buffer (decoded, best-effort)."""
deadline = time.time() + timeout
buf = ""
while time.time() < deadline:
chunk = ser.read(1024)
if chunk:
buf += chunk.decode(errors="replace")
if any(t in buf for t in terminators):
return buf
else:
time.sleep(0.1)
return buf
def _at(ser, cmd, terminators=("OK", "ERROR"), timeout=5):
"""Send one AT command and wait for a terminator. Raises CellularError
if the modem responds with ERROR or nothing recognizable within timeout."""
ser.reset_input_buffer()
ser.write((cmd + "\r\n").encode())
resp = _read_until(ser, terminators, timeout)
if "ERROR" in resp and "OK" not in resp:
raise CellularError(f"{cmd!r} -> {resp!r}")
if not any(t in resp for t in terminators):
raise CellularError(f"{cmd!r} timed out, got {resp!r}")
return resp
def _ensure_gprs_context():
"""Bring up the GPRS data context (APN attach) if not already up.
Reuses app_gps's serial connection/lock - see module docstring for why.
"""
global _gprs_context_up
with app_gps._serial_lock:
ser = app_gps._get_serial()
if _gprs_context_up:
# Cheap check that the context is still alive before trusting it.
try:
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=5)
if "." in resp and "ERROR" not in resp:
return
except CellularError:
pass
_gprs_context_up = False
_at(ser, "AT+CGATT=1", timeout=10)
apn_cmd = f'AT+CSTT="{APN}","{APN_USER}","{APN_PASS}"'
_at(ser, apn_cmd, timeout=10)
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=10)
if "ERROR" in resp or "." not in resp:
raise CellularError(f"AT+CIFSR did not return an IP: {resp!r}")
_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.
Returns (status_code, response_body_bytes). Raises CellularError on any
failure (modem not responding, no network attach, HTTP-layer failure,
etc.) - callers should catch this broadly and treat it the same as a
network-unreachable condition.
"""
if not is_configured():
raise CellularError("cellular fallback not configured (APN/enabled flag not set)")
_ensure_gprs_context()
with app_gps._serial_lock:
ser = app_gps._get_serial()
try:
_at(ser, "AT+HTTPTERM", timeout=3)
except CellularError:
pass # fine if nothing was initialized yet
_at(ser, "AT+HTTPINIT", timeout=5)
try:
_at(ser, 'AT+HTTPPARA="CID",1', timeout=5)
_at(ser, f'AT+HTTPPARA="URL","{url}"', timeout=5)
if url.lower().startswith("https"):
try:
_at(ser, "AT+HTTPSSL=1", timeout=5)
except CellularError:
# Older SIMCOM firmware/certificate stores sometimes
# don't support this - surfacing as a clear error is more
# useful than silently falling back to plain HTTP against
# an HTTPS-only endpoint.
raise CellularError(
"modem does not support AT+HTTPSSL - HTTPS upload via "
"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)
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)
_at(
ser,
f"AT+HTTPDATA={len(body_bytes)},{int(timeout * 1000)}",
terminators=("DOWNLOAD", "ERROR"),
timeout=5,
)
ser.write(body_bytes)
_read_until(ser, ("OK", "ERROR"), timeout=10)
action_resp = _at(
ser,
"AT+HTTPACTION=1",
terminators=("+HTTPACTION:",),
timeout=timeout,
)
status_code = 0
for line in action_resp.splitlines():
if "+HTTPACTION:" in line:
# +HTTPACTION: <method>,<status_code>,<data_len>
parts = line.split(":", 1)[1].strip().split(",")
if len(parts) >= 2:
status_code = int(parts[1])
break
read_resp = _at(ser, "AT+HTTPREAD", terminators=("OK", "ERROR"), timeout=15)
body = read_resp.encode(errors="replace")
return status_code, body
finally:
try:
_at(ser, "AT+HTTPTERM", timeout=5)
except CellularError:
pass
+62 -11
View File
@@ -6,6 +6,20 @@ paused independently via a persisted toggle (settings table in the same
SQLite db used for tracking) so a user can stop shipping large video files SQLite db used for tracking) so a user can stop shipping large video files
over a slow/metered connection while trip logs keep flowing. over a slow/metered connection while trip logs keep flowing.
Network path: normal WiFi/Ethernet (via `requests`) is always tried first for
everything. If a tracking-batch upload fails with a connection-level error
(no route/DNS/timeout - not an HTTP error status, which means the server WAS
reached) and the SIM868 cellular fallback is configured, the same small JSON
batch is retried over the modem's AT-command HTTP client (see
modules/app_cellular.py). Dashcam uploads never use cellular - only
WiFi/Ethernet - since video files are far too large/slow for a GPRS/3G link;
they simply wait for connectivity to return, same as before.
*** The cellular fallback path is UNTESTED (no SIM card available) - see
*** modules/app_cellular.py's docstring. It's opt-in/feature-flagged off by
*** default (YARIS_CELLULAR_ENABLED unset) so this has zero effect on the
*** already-verified WiFi upload path unless explicitly configured.
Credentials/server URL are loaded from db/upload_creds.env (gitignored, Credentials/server URL are loaded from db/upload_creds.env (gitignored,
never commit real values - mirrors the db/creds.env pattern already used by never commit real values - mirrors the db/creds.env pattern already used by
app_db.py for MongoDB). app_db.py for MongoDB).
@@ -18,7 +32,7 @@ from threading import Thread
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
from modules import app_sql, app_dash from modules import app_sql, app_dash, app_cellular
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env")) load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
@@ -38,6 +52,7 @@ DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
_upload_thread = None _upload_thread = None
_last_error = None _last_error = None
_last_success_at = None _last_success_at = None
_last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on
def _headers(): def _headers():
@@ -65,6 +80,8 @@ def get_status():
"pending_tracking_rows": app_sql.count_unsent_tracking_rows(), "pending_tracking_rows": app_sql.count_unsent_tracking_rows(),
"last_error": _last_error, "last_error": _last_error,
"last_success_at": _last_success_at, "last_success_at": _last_success_at,
"last_network_path": _last_network_path,
"cellular_fallback_configured": app_cellular.is_configured(),
} }
@@ -80,7 +97,16 @@ def _sha256_file(path):
def _upload_tracking_batch(): def _upload_tracking_batch():
"""Send up to TRACKING_BATCH_SIZE not-yet-uploaded rows. Returns count sent.""" """Send up to TRACKING_BATCH_SIZE not-yet-uploaded rows. Returns count sent.
Tries WiFi/Ethernet (`requests`) first. If that fails at the connection
level (no route, DNS failure, timeout - as opposed to the server
responding with an HTTP error, which means it WAS reached) and the
SIM868 cellular fallback is configured, retries the same batch over the
modem's AT-command HTTP client before giving up.
"""
global _last_network_path
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE) rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
if not rows: if not rows:
return 0 return 0
@@ -97,15 +123,40 @@ def _upload_tracking_batch():
} }
for row in rows for row in rows
] ]
row_ids = [row["id"] for row in rows]
resp = requests.post( try:
f"{SERVER_URL}/api/v1/tracking/batch", resp = requests.post(
json={"points": points}, f"{SERVER_URL}/api/v1/tracking/batch",
headers=_headers(), json={"points": points},
timeout=15, headers=_headers(),
) timeout=15,
resp.raise_for_status() )
app_sql.mark_tracking_rows_uploaded([row["id"] for row in rows]) resp.raise_for_status()
_last_network_path = "wifi"
except requests.exceptions.ConnectionError as wifi_err:
if not app_cellular.is_configured():
raise
print(f"WiFi/Ethernet upload unreachable ({wifi_err}), trying cellular fallback...")
import json as _json
body_bytes = _json.dumps({"points": points}).encode()
try:
status_code, _body = app_cellular.http_post_json(
f"{SERVER_URL}/api/v1/tracking/batch", body_bytes, _headers()
)
except app_cellular.CellularError as cell_err:
# Neither path worked - raise the original WiFi error so the
# normal backoff/retry logic in _upload_loop handles it.
print(f"Cellular fallback also failed: {cell_err}")
raise wifi_err
if status_code >= 400:
raise app_cellular.CellularError(
f"cellular upload got HTTP {status_code}"
)
_last_network_path = "cellular"
app_sql.mark_tracking_rows_uploaded(row_ids)
return len(rows) return len(rows)
@@ -172,7 +223,7 @@ def _upload_loop():
time.sleep(CATCHUP_INTERVAL) time.sleep(CATCHUP_INTERVAL)
else: else:
time.sleep(UPLOAD_INTERVAL) time.sleep(UPLOAD_INTERVAL)
except requests.RequestException as e: except (requests.RequestException, app_cellular.CellularError) as e:
_last_error = str(e) _last_error = str(e)
print(f"Upload failed ({e}), retrying in {backoff}s") print(f"Upload failed ({e}), retrying in {backoff}s")
time.sleep(backoff) time.sleep(backoff)
+8 -1
View File
@@ -75,7 +75,14 @@ function describeUploadStatus(status) {
} }
let text = `Pending trip-log rows to upload: ${status.pending_tracking_rows}.`; let text = `Pending trip-log rows to upload: ${status.pending_tracking_rows}.`;
if (status.last_success_at) { if (status.last_success_at) {
text += ` Last successful upload: ${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) { if (status.last_error) {
text += ` Last error: ${status.last_error}.`; text += ` Last error: ${status.last_error}.`;
+3 -1
View File
@@ -91,7 +91,9 @@
<p class="text-muted small mt-2 mb-0"> <p class="text-muted small mt-2 mb-0">
Trip/GPS log uploads always stay on. Turning this off only pauses Trip/GPS log uploads always stay on. Turning this off only pauses
the (larger) dashcam video uploads - useful on a slow or metered the (larger) dashcam video uploads - useful on a slow or metered
connection. 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> </p>
</div> </div>
</div> </div>