carview: flat steady road, one lead per car, forwarding for the hotspot

Seen live in the car: the road surface rose over the path and hid it. Lane
lines and road edges come with z from a camera tilted against the road (15 cm
higher on the right across a lane, half a metre higher 100 m out), and the
path's own z swings by metres 60 m out between frames. The road, lines and
path are now drawn flat, as on the car's own display.

Steadier: road edges the model is unsure of wandered and even crossed the
path, so each is kept 1.6-8 m to its own side of it; lines and edges are
averaged along their length past 15 m; easing is firmer (lines 0.22 s, edges
0.45 s, path 0.14 s, leads 0.3 s, from 0.08 s). The second lead was often the
first one again (both at 8.2 m), drawing two cars in one spot; it now only
draws when it's clearly another car.

The hotspot had the sharing rule but forwarding off after a reboot, so the
car got no internet: carviewd now switches net.ipv4.ip_forward on while
tethering. It reads it from /proc, since manager starts carviewd with no PATH
and sysctl is in /usr/sbin; writes go through sudo, which has its own path.

The full screen and pop-out buttons are gone: in the car full screen stays
inside the browser's panel (773x601), there's no picture-in-picture, and a
sized window.open is another tab of the same size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Isaac Barham
2026-11-06 12:16:42 -05:00
parent fce3279fd6
commit 4c4ae6170f
4 changed files with 88 additions and 75 deletions
@@ -171,6 +171,10 @@ class Hotspot:
rule = ["POSTROUTING", "-s", f"{TETHER_ADDRESS.rsplit('.', 1)[0]}.0/24", "-o", dev, "-j", "MASQUERADE"]
if self.run(["sudo", "-n", "iptables-legacy", "-t", "nat", "-C", *rule])[0] != 0:
self.run(["sudo", "-n", "iptables-legacy", "-t", "nat", "-A", *rule])
# NetworkManager doesn't always switch forwarding on either, and without it the rule above passes nothing. Read
# from /proc: manager starts carviewd with no PATH, and sysctl is in /usr/sbin (sudo has its own search path)
if self.run(["cat", "/proc/sys/net/ipv4/ip_forward"])[1].strip() != "1":
self.run(["sudo", "-n", "sysctl", "-w", "net.ipv4.ip_forward=1"])
def run_command(cmd: list[str]) -> tuple[int, str]:
@@ -44,10 +44,6 @@
#clockRow { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
#clock { font-size: 20px; color: var(--muted); font-variant-numeric: tabular-nums; }
#eta { display: none; margin-top: 6px; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
.btn { width: 36px; height: 36px; border-radius: 50%; border: 0; background: rgba(255, 255, 255, 0.75); color: var(--ink);
display: grid; place-items: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); padding: 0; }
.btn svg { width: 18px; height: 18px; }
.embed .btn { display: none; }
/* between the speed panel on the left and the buttons and clock on the right */
#alert { display: none; position: fixed; left: 50%; top: 20px; transform: translateX(-50%); width: max-content;
@@ -59,9 +55,6 @@
#status { display: none; position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); background: var(--card);
border-radius: 12px; padding: 12px 18px; font-size: 16px; color: var(--muted); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1); }
#toast { position: fixed; right: 24px; top: 70px; max-width: 320px; background: rgba(29, 31, 34, 0.9); color: #fff; font-size: 14px;
border-radius: 10px; padding: 10px 14px; opacity: 0; transition: opacity 0.25s; pointer-events: none; }
#toast.show { opacity: 1; }
</style>
</head>
<body>
@@ -90,8 +83,6 @@
<div id="topRight">
<div id="clockRow">
<button class="btn" id="popout" title="Pop out"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="13" height="13" rx="2"/><path d="M11 3h8a2 2 0 0 1 2 2v8"/></svg></button>
<button class="btn" id="full" title="Full screen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>
<div id="clock"></div>
</div>
<div id="eta"></div>
@@ -99,20 +90,15 @@
<div id="alert"><b></b><span></span></div>
<div id="status">Waiting for the comma…</div>
<div id="toast"></div>
<script type="module">
const params = new URLSearchParams(location.search);
const embedded = params.has('embed');
if (embedded) document.body.classList.add('embed');
const REPORT_S = 2;
const started = performance.now();
const m = { frames: 0, frameGapMax: 0, events: 0, eventGapMax: 0, lastEvent: 0, rtt: null, three: 'loading', threeError: '',
webgl2: !!document.createElement('canvas').getContext('webgl2'), renderer: '', fullscreen: '', popout: '', popoutSize: '' };
let car = {}, view = null, paused = false, fps = 0, fpsMin = Infinity;
webgl2: !!document.createElement('canvas').getContext('webgl2'), renderer: '' };
let car = {}, view = null, fps = 0, fpsMin = Infinity;
const $ = (id) => document.getElementById(id);
function toast(text) { const t = $('toast'); t.textContent = text; t.classList.add('show'); clearTimeout(toast.timer); toast.timer = setTimeout(() => t.classList.remove('show'), 4000); }
// ---- live data from the comma
const es = new EventSource('/events');
@@ -147,7 +133,7 @@ function frame(now) {
const dt = last ? Math.min((now - last) / 1000, 0.25) : 0;
if (last) m.frameGapMax = Math.max(m.frameGapMax, now - last);
last = now; m.frames++;
if (view && !paused) view.frame(now, dt);
if (view) view.frame(now, dt);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
@@ -209,50 +195,17 @@ setInterval(() => {
fpsMin = Math.min(fpsMin, fps); fpsFrames = m.frames; fpsTime = now;
}, 1000);
// ---- full screen and pop out
$('full').onclick = async () => {
const el = document.documentElement;
if (document.fullscreenElement) { document.exitFullscreen(); return; }
if (!el.requestFullscreen) { m.fullscreen = 'not supported'; toast("This browser can't go full screen."); return; }
try { await el.requestFullscreen({ navigationUI: 'hide' }); m.fullscreen = 'entered'; }
catch (err) { m.fullscreen = 'refused: ' + (err.message || err.name); toast("The browser refused full screen."); }
report();
};
document.addEventListener('fullscreenchange', report);
$('popout').onclick = async () => {
// ?w=&h= sets the pop-out's size
const w = Number(params.get('w')) || 960, h = Number(params.get('h')) || 540;
m.popout = ''; m.popoutSize = '';
if ('documentPictureInPicture' in window) {
try {
const pip = await window.documentPictureInPicture.requestWindow({ width: w, height: h });
pip.document.body.style.margin = '0';
const frameEl = pip.document.createElement('iframe');
frameEl.src = '/?embed'; frameEl.style.cssText = 'border:0;width:100vw;height:100vh;display:block';
pip.document.body.appendChild(frameEl);
paused = true; // one scene drawing at a time
pip.addEventListener('pagehide', () => { paused = false; m.popout += ', closed'; report(); });
m.popout = `picture-in-picture ${w}x${h}`;
setTimeout(() => { m.popoutSize = `${pip.innerWidth}x${pip.innerHeight}`; report(); }, 1500);
toast('Popped out.'); report(); return;
} catch (err) { m.popout = 'picture-in-picture refused: ' + (err.message || err.name) + '; '; }
} else m.popout = 'no picture-in-picture; ';
const win = window.open('/?embed', 'carview', `popup,width=${w},height=${h}`);
m.popout += win ? 'window.open opened' : 'window.open blocked';
toast(win ? 'Opened a pop-out window.' : 'The browser blocked the pop-out.');
if (win) setTimeout(() => { try { m.popoutSize = `${win.outerWidth}x${win.outerHeight}`; } catch (err) { m.popoutSize = 'unknown'; } report(); }, 1500);
report();
};
// No full screen or pop-out buttons: in the car the browser accepts a full-screen request but stays inside its panel
// (773x601), has no picture-in-picture, and opens a sized window.open as another tab of the same size. Only the
// browser's own expand button makes the page bigger (1256x706, over the map).
// ---- report to the comma what this browser managed
async function report() {
const body = {
page: embedded ? 'view-embed' : 'view', uptimeS: Math.round((performance.now() - started) / 1000), fps,
page: 'view', uptimeS: Math.round((performance.now() - started) / 1000), fps,
fpsMin: Number.isFinite(fpsMin) ? fpsMin : null, frames: m.frames, frameGapMaxMs: Math.round(m.frameGapMax), events: m.events,
eventGapMaxMs: Math.round(m.eventGapMax), eventSource: es.readyState, webgl2: m.webgl2, renderer: m.renderer, three: m.three,
threeError: m.threeError, pixelRatio: view ? view.pixelRatio : null, visibility: document.visibilityState,
fullscreenNow: !!document.fullscreenElement, fullscreen: m.fullscreen, popout: m.popout, popoutSize: m.popoutSize, paused,
w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio, rttMs: m.rtt, ua: navigator.userAgent,
};
m.frameGapMax = 0; m.eventGapMax = 0; fpsMin = Infinity;
@@ -12,7 +12,11 @@ const PATH_FAR = 90; // m of planned path drawn
const PATH_WIDTH = 0.9;
const LINE_WIDTH = 0.15;
const DASH = 3.0, DASH_EVERY = 12.0; // m: US lane dashes are 10 ft long with 30 ft gaps
const EASE_S = 0.08; // s: the 20 Hz model eased into the screen's 60 Hz
// easing toward each new model frame, in seconds: long enough that the model's frame-to-frame noise doesn't show,
// short enough that the road still swings round with us in a turn
const EASE_S = { line: 0.22, edge: 0.45, path: 0.14, lead: 0.3 };
const SMOOTH_FROM = 15; // m: lines and edges are averaged along their length past this, where they wobble
const EDGE_MIN = 1.6, EDGE_MAX = 8.0; // m from the path that a road edge is kept to
const LEAD_BRAKING = -1.0; // m/s^2, the lead's tail lights come on below this
const COLORS = {
@@ -100,6 +104,29 @@ const live = (THREE, geo, mat) => { const m = new THREE.Mesh(geo, mat); m.frustu
const toScene = (x, y, z) => [y, -z, -x];
// a 5-point running average along a line, only past SMOOTH_FROM: near the car the model is sharp and steady
function steady(xs, ys) {
return ys.map((y, i) => {
if (xs[i] < SMOOTH_FROM) return y;
let sum = 0, n = 0;
for (let j = Math.max(0, i - 2); j <= Math.min(ys.length - 1, i + 2); j++) { sum += ys[j]; n++; }
return sum / n;
});
}
// the path's sideways offset at model x, from its [xs, ys, zs]; held at its ends, and only ever read forward, since a
// stopped car's path folds back on itself
function alongPath(path) {
if (!path || !path[0].length) return () => 0;
const xs = [], ys = [];
path[0].forEach((x, i) => { if (!xs.length || x > xs[xs.length - 1]) { xs.push(x); ys.push(path[1][i]); } });
return (x) => {
if (x <= xs[0]) return ys[0];
for (let i = 1; i < xs.length; i++) if (x <= xs[i]) return ys[i - 1] + (ys[i] - ys[i - 1]) * (x - xs[i - 1]) / (xs[i] - xs[i - 1]);
return ys[ys.length - 1];
};
}
export function createView(THREE, canvas) {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.NeutralToneMapping;
@@ -169,16 +196,24 @@ export function createView(THREE, canvas) {
const m = car.model;
if (!m) return;
if (m.lineX && m.lineX.length && m.lineX.length + 1 !== lines[0].xs.length) [...lines, ...edges].forEach(l => l.setX(m.lineX));
// lane lines and road edges come with z from the camera (the road ~1.3 m below it), the path with z from the road:
// take the camera's height as where our lane's lines and the road edges start
const starts = [...(m.lines || []).slice(1, 3), ...(m.edges || [])].map(l => l[1][0]).filter(Number.isFinite);
const h = starts.length ? starts.reduce((a, b) => a + b, 0) / starts.length : 0;
const fromRoad = (zs) => zs.map(z => z - h);
(m.lines || []).forEach((l, i) => lines[i] && lines[i].target(l[0], fromRoad(l[1])));
(m.edges || []).forEach((e, i) => edges[i] && edges[i].target(e[0], fromRoad(e[1])));
// The road is drawn flat, as on the car's own display. Seen live: lane lines and road edges come with z from a
// camera tilted against the road (15 cm higher on the right across the lane, half a metre higher 100 m out), and the
// path's own z swings by metres 60 m out from one frame to the next, enough to hide the road behind a crest.
const lx = m.lineX || [], flat = lx.map(() => 0);
const pathY = alongPath(m.path);
(m.lines || []).forEach((l, i) => lines[i] && lines[i].target(steady(lx, l[0]), flat));
// road edges the model is unsure of wander and even cross the path: keep each on its own side, within reach
(m.edges || []).forEach((e, i) => {
if (!edges[i]) return;
const side = i === 0 ? -1 : 1;
edges[i].target(steady(lx, e[0].map((y, j) => {
const p = pathY(lx[j]), off = Math.min(EDGE_MAX, Math.max(EDGE_MIN, side * (y - p)));
return p + side * off;
})), flat);
});
(m.lineProbs || []).forEach((p, i) => { probTarget[i] = p; });
if (m.path && m.path[0].length) {
pathTarget = m.path[0].map((x, i) => [x, m.path[1][i], m.path[2][i]]);
pathTarget = m.path[0].map((x, i) => [x, m.path[1][i], 0]); // flat, like the road
if (!path || path.length !== pathTarget.length) path = pathTarget.map(p => [...p]);
}
}
@@ -187,10 +222,12 @@ export function createView(THREE, canvas) {
function frame(now, dt) {
ss.tick(now);
const k = 1 - Math.exp(-dt / EASE_S);
[...lines, ...edges].forEach(l => l.ease(k));
const ease = (s) => 1 - Math.exp(-dt / s);
const k = ease(EASE_S.line), kp = ease(EASE_S.path), kl = ease(EASE_S.lead);
lines.forEach(l => l.ease(k));
edges.forEach(l => l.ease(ease(EASE_S.edge)));
for (let i = 0; i < 4; i++) lineProbs[i] += (probTarget[i] - lineProbs[i]) * k;
if (path && pathTarget) for (let i = 0; i < path.length; i++) for (let j = 0; j < 3; j++) path[i][j] += (pathTarget[i][j] - path[i][j]) * k;
if (path && pathTarget) for (let i = 0; i < path.length; i++) for (let j = 0; j < 3; j++) path[i][j] += (pathTarget[i][j] - path[i][j]) * kp;
v += ((car.vEgo || 0) - v) * Math.min(1, dt * 4);
distance += v * dt;
@@ -232,16 +269,18 @@ export function createView(THREE, canvas) {
pathMat.color.setHex(car.engaged ? COLORS.pathOn : car.mads ? COLORS.pathSteer : COLORS.pathOff);
pathMat.opacity = car.engaged || car.mads ? 1 : 0.55;
// leads: dRel from our front bumper, yRel to the left
const leads = car.leads || [];
// leads: dRel from our front bumper, yRel to the left. The second lead is often the first one again (seen live:
// both at 8.2 m), which drew two cars in one spot, so it only counts when it's clearly another car.
const all = car.leads || [];
const leads = all.filter((l, i) => i === 0 || Math.abs(l.d - all[0].d) > 4 || Math.abs(l.y - all[0].y) > 1.5);
leadCars.forEach((lc, i) => {
const lead = leads[i], st = leadState[i];
if (!lead) { lc.visible = false; st.on = false; return; }
const x = CAMERA_TO_FRONT + lead.d + lc.userData.length / 2, [, z] = lines[1].at(x);
const tx = -lead.y, tz = -x;
const x = CAMERA_TO_FRONT + lead.d + lc.userData.length / 2;
const tx = -Math.max(-8, Math.min(8, lead.y)), tz = -x;
if (!st.on) { st.x = tx; st.z = tz; st.on = true; }
st.x += (tx - st.x) * k; st.z += (tz - st.z) * k;
lc.position.set(st.x, -z, st.z);
st.x += (tx - st.x) * kl; st.z += (tz - st.z) * kl;
lc.position.set(st.x, 0, st.z);
lc.visible = true;
lc.userData.setBraking(lead.a < LEAD_BRAKING);
});
@@ -191,8 +191,9 @@ def test_view_state_leaves_out_a_model_that_isnt_running():
class FakeNet:
"""ip and iptables-legacy as the hotspot helper sees them, recording what it asks for."""
def __init__(self, tethering, car_address, nat, uplink="ppp0"):
def __init__(self, tethering, car_address, nat, uplink="ppp0", forwarding=True):
self.tethering, self.car_address, self.nat, self.uplink, self.calls = tethering, car_address, nat, uplink, []
self.forwarding = forwarding
def __call__(self, cmd):
self.calls.append(cmd)
@@ -207,6 +208,8 @@ class FakeNet:
return 0, f"default via 10.64.64.64 dev {self.uplink} metric 1000\n" if self.uplink else ""
if "-C" in cmd:
return (0 if self.nat else 1), ""
if cmd == ["cat", "/proc/sys/net/ipv4/ip_forward"]:
return 0, "1\n" if self.forwarding else "0\n"
return 0, ""
def asked(self, *words):
@@ -223,14 +226,28 @@ def test_hotspot_on_gets_the_car_address_and_internet_sharing():
def test_hotspot_already_set_up_is_left_alone():
net = FakeNet(tethering=True, car_address=True, nat=True)
carviewd.Hotspot(net).update()
assert not net.asked("sudo", "add") and not net.asked("-A")
assert not net.asked("sudo", "add") and not net.asked("-A") and not net.asked("sysctl", "-w")
def test_hotspot_switches_forwarding_on_when_it_is_off():
# seen on the comma: the sharing rule in place but forwarding off, so the car got no internet
net = FakeNet(tethering=True, car_address=True, nat=True, forwarding=False)
carviewd.Hotspot(net).update()
assert net.asked("sudo", "sysctl", "-w", "net.ipv4.ip_forward=1")
def test_off_the_hotspot_the_car_address_is_taken_away():
net = FakeNet(tethering=False, car_address=True, nat=False)
carviewd.Hotspot(net).update()
assert net.asked("sudo", "addr", "del", f"{carviewd.CAR_ADDRESS}/32", "lo")
assert not net.asked("iptables-legacy")
assert not net.asked("iptables-legacy") and not net.asked("sysctl") and not net.asked("/proc/sys/net/ipv4/ip_forward")
def test_reads_nothing_it_cant_find_without_a_path():
# manager starts carviewd with no PATH: only /bin and /usr/bin resolve, the rest have to go through sudo
net = FakeNet(tethering=True, car_address=False, nat=False, forwarding=False)
carviewd.Hotspot(net).update()
assert all(c[0] in ("ip", "cat", "sudo") for c in net.calls)
def test_no_uplink_means_no_sharing_rule():