From 74bfc08d4af598835a64286dd22abf17ce3c4471 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 19:56:53 +0000 Subject: [PATCH 1/5] Initial plan From 3f44b6fb85e3b2b6b901f7181197c6000a2ba5f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 20:06:20 +0000 Subject: [PATCH 2/5] Add PWA support: manifest, service worker, offline page, and icon --- .../DESIGN-CONTROLLER/BASE_STATION.md | 55 ++++++++ .../base_station/tests/test_web_ui_pwa.py | 132 ++++++++++++++++++ .../base_station/web/icons/icon.svg | 29 ++++ .../base_station/web/index.html | 25 ++++ .../base_station/web/manifest.json | 39 ++++++ .../base_station/web/offline.html | 53 +++++++ .../DESIGN-CONTROLLER/base_station/web/sw.js | 110 +++++++++++++++ .../DESIGN-CONTROLLER/base_station/web_ui.py | 71 +++++++++- 8 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/manifest.json create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/offline.html create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/sw.js diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md b/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md index 621fe302..31256f83 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md +++ b/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md @@ -189,6 +189,61 @@ All topics under `lifetrac/v25/`. Mosquitto runs in a container on the X8 and th - Firewall: allow inbound 80/443 from LAN only; Mosquitto 1883 LAN-only; never expose to public internet - TLS: self-signed cert acceptable for LAN; use Let's Encrypt only if the base station is behind a publicly-routable hostname +## HTTPS / PWA setup + +The operator console ships as a **Progressive Web App (PWA)**: it includes a +web-app manifest (`/manifest.json`) and a service worker (`/sw.js`) that +caches the app shell for fast load and provides a graceful offline page when +the base station is temporarily unreachable. + +**PWA features require a secure origin** (HTTPS or `http://localhost`). On a +LAN the practical options are: + +### Option A — Self-signed certificate (recommended) + +```sh +# On the base-station device (or any host with openssl): +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /etc/lifetrac/key.pem \ + -out /etc/lifetrac/cert.pem \ + -days 3650 \ + -subj "/CN=lifetrac-base" \ + -addext "subjectAltName=IP:192.168.1.42,DNS:lifetrac-base.local,DNS:localhost" + +# Run the web UI with TLS (port 8443 by convention): +uvicorn web_ui:app --host 0.0.0.0 --port 8443 \ + --ssl-keyfile /etc/lifetrac/key.pem \ + --ssl-certfile /etc/lifetrac/cert.pem +``` + +Each operator device must accept the self-signed certificate once. For +Android / iOS install the cert as a trusted CA (`cert.pem`) to permanently +dismiss the browser warning and enable the **Add to Home Screen** prompt. + +### Option B — Reverse proxy (nginx or Caddy) + +Place an HTTPS proxy in front of the FastAPI server on port 8080. Caddy with +`tls internal` issues a local-network-trusted cert automatically if the +`mkcert` root CA has been installed on client devices. + +### Option C — localhost only + +If the operator tablet *is* the base-station device, access the UI at +`http://localhost:8080`. Browsers treat `localhost` as a secure origin and +the service worker will register normally without any certificate setup. + +### What works without HTTPS + +The full operator UI — joystick control, live telemetry, video stream — works +over plain HTTP. Only the PWA-specific features are gated on a secure origin: + +| Feature | Plain HTTP | HTTPS / localhost | +|--------------------------|:----------:|:-----------------:| +| Operator console | ✅ | ✅ | +| Manifest (install prompt)| ✅ link only| ✅ with install | +| Service worker / offline | ❌ | ✅ | +| Add to Home Screen | ❌ | ✅ | + ## Link-loss behavior When the LoRa link to the tractor is lost (no telemetry for 30 s), the base UI shows a banner, stops sending joystick `ControlFrame`s, and leaves E-stop available. There is no Cat-M1 or MQTT-over-cellular fallback in v25; recovery is LoRa link restoration or a local hardware/service intervention. diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py new file mode 100644 index 00000000..bb9b5325 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py @@ -0,0 +1,132 @@ +"""Tests for the PWA service-worker and manifest routes. + +Covers the /sw.js and /manifest.json root-level endpoints added to +enable Progressive Web App features on the base-station operator console. +""" + +from __future__ import annotations + +import importlib +import json +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +try: + import paho.mqtt.client # noqa: F401 + import fastapi # noqa: F401 + from fastapi.testclient import TestClient # noqa: F401 +except ImportError: + raise unittest.SkipTest("paho-mqtt + fastapi required for web_ui PWA tests") + + +class PwaRoutesTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + os.environ["LIFETRAC_PIN"] = "424242" + with mock.patch("paho.mqtt.client.Client") as mqtt_class: + instance = mqtt_class.return_value + instance.connect = mock.MagicMock() + instance.loop_start = mock.MagicMock() + instance.subscribe = mock.MagicMock() + instance.publish = mock.MagicMock() + import web_ui + importlib.reload(web_ui) + cls.client = TestClient(web_ui.app) + + # ---- service worker ------------------------------------------------ + + def test_sw_js_returns_200(self): + r = self.client.get("/sw.js") + self.assertEqual(r.status_code, 200) + + def test_sw_js_content_type_is_javascript(self): + r = self.client.get("/sw.js") + self.assertIn("javascript", r.headers.get("content-type", "")) + + def test_sw_js_service_worker_allowed_header_is_root(self): + """Service-Worker-Allowed must be '/' so the SW can control all pages.""" + r = self.client.get("/sw.js") + self.assertEqual(r.headers.get("service-worker-allowed"), "/") + + def test_sw_js_cache_control_prevents_caching(self): + """The SW file itself must never be cached by the browser.""" + r = self.client.get("/sw.js") + cc = r.headers.get("cache-control", "") + self.assertIn("no-cache", cc) + + def test_sw_js_body_contains_cache_name(self): + r = self.client.get("/sw.js") + self.assertIn("CACHE_NAME", r.text) + + def test_sw_js_never_intercepts_api_or_ws(self): + """The SW source must explicitly skip /api/ and /ws/ paths.""" + r = self.client.get("/sw.js") + self.assertIn("/api/", r.text) + self.assertIn("/ws/", r.text) + + # ---- manifest ------------------------------------------------------- + + def test_manifest_returns_200(self): + r = self.client.get("/manifest.json") + self.assertEqual(r.status_code, 200) + + def test_manifest_content_type(self): + r = self.client.get("/manifest.json") + ct = r.headers.get("content-type", "") + self.assertIn("manifest+json", ct) + + def test_manifest_required_fields(self): + r = self.client.get("/manifest.json") + m = r.json() + self.assertIn("name", m) + self.assertIn("short_name", m) + self.assertIn("start_url", m) + self.assertIn("display", m) + self.assertIn("icons", m) + + def test_manifest_start_url_is_root(self): + r = self.client.get("/manifest.json") + self.assertEqual(r.json()["start_url"], "/") + + def test_manifest_icons_reference_static_path(self): + r = self.client.get("/manifest.json") + icons = r.json().get("icons", []) + self.assertTrue(len(icons) >= 1, "manifest must declare at least one icon") + for icon in icons: + self.assertTrue( + icon["src"].startswith("/static/"), + f"icon src should be under /static/: {icon['src']}", + ) + + def test_manifest_display_is_standalone_or_fullscreen(self): + r = self.client.get("/manifest.json") + self.assertIn(r.json()["display"], ("standalone", "fullscreen", "minimal-ui")) + + # ---- offline page via static mount ---------------------------------- + + def test_offline_html_accessible(self): + """The offline fallback page must be reachable without a session cookie.""" + r = self.client.get("/static/offline.html") + self.assertEqual(r.status_code, 200) + self.assertIn("text/html", r.headers.get("content-type", "")) + + def test_offline_html_has_reload_button(self): + r = self.client.get("/static/offline.html") + self.assertIn("reload", r.text.lower()) + + # ---- icon accessible ----------------------------------------------- + + def test_icon_svg_accessible(self): + r = self.client.get("/static/icons/icon.svg") + self.assertEqual(r.status_code, 200) + self.assertIn("svg", r.headers.get("content-type", "").lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg new file mode 100644 index 00000000..d7b09806 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg @@ -0,0 +1,29 @@ + diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html index 46c320cf..8eb7916e 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html @@ -12,6 +12,13 @@
The LifeTrac operator console could not be reached.
+ The base station may still be starting up, or the LAN
+ connection may have dropped.