Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bluepilot/backend/bp_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,7 @@ def do_GET(self):
'/api/logs',
'/api/manager-logs',
'/api/websocket_status',
'/api/lateral',
'/api/drive-stats',
'/api/panels',
]
Expand All @@ -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
Expand Down
157 changes: 157 additions & 0 deletions bluepilot/backend/realtime/lateral_stream.py
Original file line number Diff line number Diff line change
@@ -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()
175 changes: 175 additions & 0 deletions bluepilot/web/public/lateral.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#0d0f14">
<title>Steering — BluePilot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; background: #0d0f14; color: #e8e9ec; overflow: hidden;
font: 14px/1.4 -apple-system, "Segoe UI", Roboto, sans-serif; }
#app { display: flex; flex-direction: column; height: 100%; }

header { display: flex; align-items: center; gap: 14px; padding: 8px 12px;
border-bottom: 1px solid #22262e; flex-wrap: wrap; }
.stat { display: flex; flex-direction: column; min-width: 58px; }
.stat .v { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat .l { font-size: 10px; color: #8b8f98; text-transform: uppercase; letter-spacing: .06em; }
.stat .v.on { color: #4cd07d; }
.stat .v.off { color: #8b8f98; }
#autocal { font-size: 11px; color: #8b8f98; margin-left: auto; text-align: right;
max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#conn { width: 9px; height: 9px; border-radius: 50%; background: #d64545; flex: none; }
#conn.ok { background: #4cd07d; }

#graphwrap { flex: 1; position: relative; min-height: 0; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; }


.legend { position: absolute; top: 6px; left: 10px; display: flex; gap: 14px; font-size: 12px; }
.legend span::before { content: ""; display: inline-block; width: 14px; height: 3px;
border-radius: 2px; margin-right: 5px; vertical-align: middle; }
.legend .des::before { background: #4da3ff; }
.legend .act::before { background: #4cd07d; }
#pausedbadge { position: absolute; top: 6px; right: 10px; font-size: 12px; font-weight: 700;
color: #ffb84d; display: none; letter-spacing: .05em; }
</style>
</head>
<body>
<div id="app">
<header>
<div id="conn" title="stream status"></div>
<div class="stat"><div class="v" id="mph">--</div><div class="l">mph</div></div>
<div class="stat"><div class="v" id="lat">--</div><div class="l">lateral</div></div>
<div class="stat"><div class="v" id="err">--</div><div class="l">err °</div></div>
<div class="stat"><div class="v" id="factors" style="font-size:13px"></div>
<div class="l">low / high</div></div>
<div id="autocal">connecting…</div>
</header>

<div id="graphwrap">
<canvas id="graph"></canvas>
<div class="legend"><span class="des">Desired</span><span class="act">Actual</span></div>
<div id="pausedbadge">PAUSED — tap to resume</div>
</div>

</div>

<script>
'use strict';
// ---- state ------------------------------------------------------------------------------
const WINDOW_S = 12; // rolling window, mirrors the on-device feel but longer
const buf = []; // {t, des, act}
let paused = false;
let lastFrameWall = 0;

// ---- stream -----------------------------------------------------------------------------
const conn = document.getElementById('conn');
function connect() {
const es = new EventSource('/api/lateral/stream');
es.onopen = () => conn.classList.add('ok');
es.onerror = () => { conn.classList.remove('ok'); es.close(); setTimeout(connect, 2000); };
es.onmessage = (ev) => {
const d = JSON.parse(ev.data);
lastFrameWall = performance.now();
if (!paused && Math.abs(d.desired_deg) < 600 && Math.abs(d.actual_deg) < 600) {
buf.push({ t: d.t, des: d.desired_deg, act: d.actual_deg });
const cut = d.t - WINDOW_S;
while (buf.length && buf[0].t < cut) buf.shift();
}
document.getElementById('mph').textContent = d.v_mph.toFixed(0);
const lat = document.getElementById('lat');
lat.textContent = d.lat_active ? 'ON' : 'off';
lat.className = 'v ' + (d.lat_active ? 'on' : 'off');
document.getElementById('err').textContent = (d.desired_deg - d.actual_deg).toFixed(1);
document.getElementById('autocal').textContent = d.autocal || 'autocal: —';
document.getElementById('factors').textContent =
(d.low_factor && d.high_factor)
? `factors ${d.low_factor.toFixed(2)} / ${d.high_factor.toFixed(2)}`
: '';
};
}
connect();

// ---- tap to pause/resume (whole graph is the control — no chrome) -----------------------
document.getElementById('graphwrap').addEventListener('click', () => {
paused = !paused;
document.getElementById('pausedbadge').style.display = paused ? 'block' : 'none';
});

// ---- graph ------------------------------------------------------------------------------
const cv = document.getElementById('graph');
const ctx = cv.getContext('2d');
function draw() {
const w = cv.clientWidth, h = cv.clientHeight, dpr = window.devicePixelRatio || 1;
if (cv.width !== w * dpr || cv.height !== h * dpr) {
cv.width = w * dpr; cv.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const padL = 40, padR = 8, padT = 10, padB = 20;
const gw = w - padL - padR, gh = h - padT - padB;

// autoscale: symmetric about zero, min ±5°, headroom
let m = 5;
for (const p of buf) m = Math.max(m, Math.abs(p.des), Math.abs(p.act));
m *= 1.15;
const t1 = buf.length ? buf[buf.length - 1].t : 0;
const x = (t) => padL + gw * (1 - (t1 - t) / WINDOW_S);
const y = (v) => padT + gh * (0.5 - v / (2 * m));

// grid + labels
ctx.strokeStyle = '#1d222b'; ctx.fillStyle = '#666c78';
ctx.font = '11px sans-serif'; ctx.textAlign = 'right'; ctx.lineWidth = 1;
const rawStep = (2 * m) / 8;
const pow10 = Math.pow(10, Math.floor(Math.log10(rawStep)));
const step = [1, 2, 5, 10].map(k => k * pow10).find(s => s >= rawStep) || 10 * pow10;
for (let v = -Math.floor(m / step) * step; v <= m; v += step) {
ctx.beginPath(); ctx.moveTo(padL, y(v)); ctx.lineTo(w - padR, y(v)); ctx.stroke();
ctx.fillText(v + '°', padL - 5, y(v) + 4);
}
ctx.strokeStyle = '#2a3038';
ctx.beginPath(); ctx.moveTo(padL, y(0)); ctx.lineTo(w - padR, y(0)); ctx.stroke();

// time ticks every 2 s
ctx.textAlign = 'center';
for (let s = 0; s <= WINDOW_S; s += 2) {
const tx = x(t1 - s);
if (tx >= padL) ctx.fillText(s === 0 ? 'now' : '-' + s + 's', tx, h - 6);
}

const trace = (key, color) => {
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath();
let started = false;
for (const p of buf) {
const px = x(p.t), py = y(p[key]);
if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
}
ctx.stroke();
};
trace('des', '#4da3ff');
trace('act', '#4cd07d');

// stale-stream veil
if (performance.now() - lastFrameWall > 2000) {
ctx.fillStyle = 'rgba(13,15,20,0.55)'; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#8b8f98'; ctx.textAlign = 'center'; ctx.font = '15px sans-serif';
ctx.fillText('waiting for data…', w / 2, h / 2);
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

// keep the screen awake while watching, where supported
if ('wakeLock' in navigator) {
let lock = null;
const acquire = () => navigator.wakeLock.request('screen').then(l => lock = l).catch(() => {});
acquire();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') acquire();
});
}
</script>
</body>
</html>
37 changes: 37 additions & 0 deletions docs/lateral-phone-graph.md
Original file line number Diff line number Diff line change
@@ -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://<device-ip>: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.