From 9652daa24e47bf686fa88e3c1b8a3b40be6a411a Mon Sep 17 00:00:00 2001 From: ghbarker <117389362+ghbarker@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:53:36 -0400 Subject: [PATCH 1/2] portal: live lateral debug graph for phones (/lateral) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MICI lateral debug screen is too small for more than a gut feeling. The portal now serves the same signals to any phone on the device hotspot/LAN: - /lateral — self-contained page: desired vs actual steering angle on a rolling 12 s canvas (autoscaled symmetric about zero, 1-2-5 grid ladder at any scale), mph / lateral-active / live error / factors readouts, tap-anywhere pause, and a screen wake-lock. View-only by design — adjustments stay on device menus. Samples beyond +/-600 degrees (past any physical wheel) are dropped at ingest so a CAN glitch or replay seam can never blow the scale. - /api/lateral/stream — 20 Hz SSE. A singleton reader (realtime/lateral_stream) owns a SubMaster on carState/carControl/controllerStateBP, starts on the first watcher and stops after 10 s idle, so the portal costs nothing while nobody is looking. Messaging imports lazily, matching log_streamer. Fields owned by other branches (the auto-cal status string) are read defensively — a dash on the page instead of a dead stream on schemas without them. - /api/lateral joins the onroad allowlist — watching while driving is the point. Measured on a replay-fed rig: metronomic 20 fps (50 +/- 1 ms gaps, zero drops over 500+ frames), 12 ms median capture-to-client, ~4% of one CPU core while streaming and zero while idle. Confirmed working on-car from a phone. Co-Authored-By: Claude Fable 5 --- bluepilot/backend/bp_portal.py | 11 ++ bluepilot/backend/realtime/lateral_stream.py | 157 +++++++++++++++++ bluepilot/web/public/lateral.html | 175 +++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 bluepilot/backend/realtime/lateral_stream.py create mode 100644 bluepilot/web/public/lateral.html diff --git a/bluepilot/backend/bp_portal.py b/bluepilot/backend/bp_portal.py index 5036200460..be1e8be66d 100644 --- a/bluepilot/backend/bp_portal.py +++ b/bluepilot/backend/bp_portal.py @@ -879,6 +879,7 @@ def do_GET(self): '/api/logs', '/api/manager-logs', '/api/websocket_status', + '/api/lateral', '/api/drive-stats', '/api/panels', ] @@ -903,6 +904,16 @@ def do_GET(self): self.send_file_response(str(WEBAPP_DIR / 'index.html'), 'text/html') return + # Live lateral debug graph for phones (self-contained page, works onroad — + # that is its purpose; see realtime/lateral_stream.py) + if path == '/lateral': + self.send_file_response(str(WEBAPP_DIR / 'lateral.html'), 'text/html') + return + if path == '/api/lateral/stream': + from bluepilot.backend.realtime.lateral_stream import serve_sse + serve_sse(self) + return + # API routes - separate if/elif chain since SPA routes return early if path == '/api/health': # Health check endpoint for monitoring diff --git a/bluepilot/backend/realtime/lateral_stream.py b/bluepilot/backend/realtime/lateral_stream.py new file mode 100644 index 0000000000..bfc10dd290 --- /dev/null +++ b/bluepilot/backend/realtime/lateral_stream.py @@ -0,0 +1,157 @@ +""" +BluePilot portal: live lateral-debug feed for the phone graph (/lateral). + +The MICI lateral debug screen shows desired vs actual steering angle on a display +that is honestly too small for more than a gut feeling. This module gives the same +signals to any phone on the device's hotspot/LAN as a 20 Hz snapshot stream the +portal serves over SSE. + +Design mirrors realtime/log_streamer.py: messaging is imported lazily (the portal +must keep working on hosts without cereal), a single background reader thread owns +the SubMaster, and it starts on the first subscriber and stops after a short idle +so the portal costs nothing while nobody is watching. +""" + +import threading +import time +import logging + +logger = logging.getLogger(__name__) + +_RATE_HZ = 20.0 +_IDLE_STOP_S = 10.0 # reader stops this long after the last subscriber detaches + + +class LateralFeed: + """Singleton owner of the messaging reader. Thread-safe snapshot access.""" + + _instance = None + _instance_lock = threading.Lock() + + @classmethod + def instance(cls) -> "LateralFeed": + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self._lock = threading.Lock() + self._thread = None + self._subscribers = 0 + self._last_sub_gone = 0.0 + self._seq = 0 + self._sample = {} + + # -- subscriber lifecycle ------------------------------------------------------------- + def attach(self): + with self._lock: + self._subscribers += 1 + if self._thread is None or not self._thread.is_alive(): + self._thread = threading.Thread(target=self._run, daemon=True, + name="lateral_feed") + self._thread.start() + + def detach(self): + with self._lock: + self._subscribers = max(0, self._subscribers - 1) + if self._subscribers == 0: + self._last_sub_gone = time.monotonic() + + def snapshot(self, last_seq: int): + """(seq, sample) if newer than last_seq else (last_seq, None).""" + with self._lock: + if self._seq == last_seq: + return last_seq, None + return self._seq, dict(self._sample) + + # -- reader --------------------------------------------------------------------------- + def _should_stop(self) -> bool: + with self._lock: + return (self._subscribers == 0 + and time.monotonic() - self._last_sub_gone > _IDLE_STOP_S) + + def _run(self): + try: + import cereal.messaging as messaging + except Exception as exc: + logger.error("lateral feed: messaging unavailable: %s", exc) + return + try: + sm = messaging.SubMaster(['carState', 'carControl', 'controllerStateBP']) + except Exception as exc: + logger.error("lateral feed: SubMaster failed: %s", exc) + return + logger.info("lateral feed: reader started") + period = 1.0 / _RATE_HZ + while not self._should_stop(): + t0 = time.monotonic() + try: + sm.update(int(period * 1000)) + cs = sm['carState'] + cc = sm['carControl'] + st = sm['controllerStateBP'] + sample = { + 't': time.time(), + 'desired_deg': float(cc.actuators.steeringAngleDeg), + 'actual_deg': float(cs.steeringAngleDeg), + 'v_mph': float(cs.vEgo) * 2.23694, + 'lat_active': bool(cc.latActive), + 'torque_nm': float(cs.steeringTorque), + 'low_factor': float(st.bmsLowSpeedAdjustmentFactor), + 'high_factor': float(st.bmsHighSpeedAdjustmentFactor), + # Fields other branches own read defensively: the page shows a dash + # instead of this stream dying on a schema without them (the auto-cal + # status string ships with the ford-angle-autocal branch). + 'autocal': str(getattr(st, 'bmsAngleAutoCalState', '')), + 'alive': bool(sm.alive['carState'] and sm.alive['carControl']), + } + with self._lock: + self._sample = sample + self._seq += 1 + except Exception as exc: + # keep the reader alive through transient messaging hiccups + logger.debug("lateral feed: update error: %s", exc) + dt = time.monotonic() - t0 + if dt < period: + time.sleep(period - dt) + logger.info("lateral feed: reader stopped (idle)") + + +def serve_sse(handler): + """Write the SSE stream onto a BaseHTTPRequestHandler until the client leaves. + + One thread per watching phone (the portal is a ThreadingHTTPServer); frames are + only sent when the feed sequence advances, so a paused car costs near nothing. + """ + import json as _json + + feed = LateralFeed.instance() + feed.attach() + try: + handler.send_response(200) + handler.send_header('Content-Type', 'text/event-stream') + handler.send_header('Cache-Control', 'no-cache') + handler.send_header('Connection', 'keep-alive') + handler.send_header('Access-Control-Allow-Origin', '*') + handler.end_headers() + seq = 0 + last_beat = time.monotonic() + while True: + seq, sample = feed.snapshot(seq) + now = time.monotonic() + if sample is not None: + payload = f"data: {_json.dumps(sample, separators=(',', ':'))}\n\n" + handler.wfile.write(payload.encode()) + handler.wfile.flush() + last_beat = now + elif now - last_beat > 5.0: + # comment-frame keepalive so phones detect a dead link promptly + handler.wfile.write(b": keepalive\n\n") + handler.wfile.flush() + last_beat = now + time.sleep(1.0 / _RATE_HZ / 2.0) + except (BrokenPipeError, ConnectionResetError, OSError): + pass # phone left / screen locked — normal end of stream + finally: + feed.detach() diff --git a/bluepilot/web/public/lateral.html b/bluepilot/web/public/lateral.html new file mode 100644 index 0000000000..2660524510 --- /dev/null +++ b/bluepilot/web/public/lateral.html @@ -0,0 +1,175 @@ + + + + + + +Steering — BluePilot + + + +
+
+
+
--
mph
+
--
lateral
+
--
err °
+
+
low / high
+
connecting…
+
+ +
+ +
DesiredActual
+
PAUSED — tap to resume
+
+ +
+ + + + From bd869ef535cfcb64540bccc6c6c5f2517d1c1509 Mon Sep 17 00:00:00 2001 From: ghbarker <117389362+ghbarker@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:29:08 -0400 Subject: [PATCH 2/2] docs: lateral phone graph user guide Co-Authored-By: Claude Fable 5 --- docs/lateral-phone-graph.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/lateral-phone-graph.md diff --git a/docs/lateral-phone-graph.md b/docs/lateral-phone-graph.md new file mode 100644 index 0000000000..89a4e9cb29 --- /dev/null +++ b/docs/lateral-phone-graph.md @@ -0,0 +1,37 @@ +# Lateral Debug Graph on Your Phone — User Guide + +The steering-angle debug view, on a screen big enough to actually read. + +## What it is + +A live graph of **Desired vs Actual steering angle** — the same comparison the +on-device lateral debug screen shows — served by the device to any phone on its +network. 20 updates per second, about 50 ms behind the wheel. Use it to watch how +tightly the car tracks its own commands while tuning, without squinting at the +device screen. + +## Using it + +1. Enable **Web Routes Server** in settings (the same toggle that powers route + browsing). +2. Connect your phone to the device's hotspot (or have both on the same WiFi). +3. Open **`http://192.168.43.1:8088/lateral`** (hotspot) or `http://:8088/lateral`. +4. Add it to your home screen if you like — it behaves like an app and keeps your + screen awake while open. + +On screen: current mph, whether lateral is active, the live desired-minus-actual +error in degrees, your low/high adjustment factors, and the auto-calibration +status line. **Tap anywhere on the graph to freeze it** for a closer look; tap +again to resume. The scale adapts automatically — gentle highway corrections and +full-lock parking maneuvers both stay readable. + +## Good to know + +- **View-only.** Nothing on this page changes any setting; adjustments stay on + the device menus. +- It costs nothing while closed: the data feed starts when the first phone + connects and shuts off ten seconds after the last one leaves. +- If the connection drops (screen lock, walking away), the page shows + "waiting for data…" and reconnects by itself — a frozen trace is never + silently presented as live. +- Works while driving; that's the point. Have a passenger hold the phone.