diff --git a/PLAN.md b/PLAN.md index 9452f94..4450ba5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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. +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 -uplink for uploads when no network path to the server is available at all (today uploads need -the Pi to reach `yaris.ceaf.dev`, e.g. via home WiFi) — remains a possible future enhancement. +### SIM868 cellular fallback for tracking uploads — built, UNTESTED (no SIM card available) +`modules/app_cellular.py` gives the tracking-upload path a fallback over the SIM868's cellular +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 diff --git a/db/upload_creds.env b/db/upload_creds.env index 1a54721..6f2bae6 100644 --- a/db/upload_creds.env +++ b/db/upload_creds.env @@ -10,3 +10,17 @@ YARIS_SERVER_URL = "" YARIS_DEVICE_NAME = "" 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 = "" diff --git a/modules/app_cellular.py b/modules/app_cellular.py new file mode 100644 index 0000000..4cb9868 --- /dev/null +++ b/modules/app_cellular.py @@ -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: ,, + 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 diff --git a/modules/app_upload.py b/modules/app_upload.py index f6c158d..c077a15 100644 --- a/modules/app_upload.py +++ b/modules/app_upload.py @@ -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 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, never commit real values - mirrors the db/creds.env pattern already used by app_db.py for MongoDB). @@ -18,7 +32,7 @@ from threading import Thread import requests 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__))) 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 _last_error = None _last_success_at = None +_last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on def _headers(): @@ -65,6 +80,8 @@ def get_status(): "pending_tracking_rows": app_sql.count_unsent_tracking_rows(), "last_error": _last_error, "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(): - """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) if not rows: return 0 @@ -97,15 +123,40 @@ def _upload_tracking_batch(): } for row in rows ] + row_ids = [row["id"] for row in rows] - resp = requests.post( - f"{SERVER_URL}/api/v1/tracking/batch", - json={"points": points}, - headers=_headers(), - timeout=15, - ) - resp.raise_for_status() - app_sql.mark_tracking_rows_uploaded([row["id"] for row in rows]) + try: + resp = requests.post( + f"{SERVER_URL}/api/v1/tracking/batch", + json={"points": points}, + headers=_headers(), + timeout=15, + ) + 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) @@ -172,7 +223,7 @@ def _upload_loop(): time.sleep(CATCHUP_INTERVAL) else: time.sleep(UPLOAD_INTERVAL) - except requests.RequestException as e: + except (requests.RequestException, app_cellular.CellularError) as e: _last_error = str(e) print(f"Upload failed ({e}), retrying in {backoff}s") time.sleep(backoff) diff --git a/static/js/dash.js b/static/js/dash.js index 331eb3e..3e3ed33 100644 --- a/static/js/dash.js +++ b/static/js/dash.js @@ -75,7 +75,14 @@ function describeUploadStatus(status) { } let text = `Pending trip-log rows to upload: ${status.pending_tracking_rows}.`; 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) { text += ` Last error: ${status.last_error}.`; diff --git a/templates/dash.html b/templates/dash.html index 322262d..9402fd4 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -91,7 +91,9 @@

Trip/GPS log uploads always stay on. Turning this off only pauses 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.