carview: the real driving view, and the comma's hotspot set up for the car

The page at / is now a driving view built from the comma's messages: the
model's road surface between its road edges, the four lane lines (ours dashed
and flowing past at our speed, faded by the model's confidence), the planned
path (blue engaged, lighter under MADS, grey off), up to two clay lead cars
whose tail lights show hard braking, and our own car. The HUD has gear, speed,
set speed, the speed limit, the MADS wheel, blinkers, alerts with their status
colour, and a nav card with ETA. Blind spots turn a lane line red. The
original browser test moves to /test.

carviewd streams it all at 20 Hz (~4 KB a frame, the road geometry kept out of
the report log). Lane lines and road edges come with z from the camera, the
path with z from the road, so the page measures the camera's height from the
model's own lines. Leads use LeadData.present: this LeadData has no status.

The car's browser refuses private addresses, so the car joins the comma's own
hotspot and opens http://3.3.3.3:8099. While tethering is on, carviewd puts
3.3.3.3 on lo and shares the comma's internet with the hotspot through
iptables-legacy: NetworkManager's own sharing fails on this kernel, and a car
on a hotspot without internet drops it. Off the hotspot 3.3.3.3 is removed.

The YouTube-redirect "fullscreen" is gone (it did nothing in the car): full
screen asks the browser itself, and pop out tries Document Picture-in-Picture
at ?w=&h= before a sized window.open, each reported back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Isaac Barham
2026-11-06 11:49:53 -05:00
parent 9a3741250d
commit fce3279fd6
5 changed files with 996 additions and 250 deletions
+130 -16
View File
@@ -5,13 +5,16 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
carview test server: a page for the car's own browser, fed live from the comma.
carview: a driving view for the car's own browser, fed live from the comma.
It serves static/index.html on the local network and streams a small snapshot of the car's state to it as server-sent
events. Every few seconds the page posts back what it measured (frame rate, WebGL, how steadily the data arrives), and
each report is logged with the car's state at that moment, so one drive shows whether the car's browser keeps drawing
and receiving while in Drive. It only reads messages and serves HTTP: it can't affect driving, and selfdrived ignores it
if it dies. It runs only while the enabled file in carview_root() exists.
It serves static/index.html (the view; /test is the original browser test) on the local network and streams the car,
the model's road and path, leads, alerts and cues to it as server-sent events. Every few seconds the page posts back what
it measured (frame rate, WebGL, how steadily the data arrives), and each report is logged with the car's state at that
moment, so a drive shows how well the car's browser kept up. It only reads messages and serves HTTP: it can't affect
driving, and selfdrived ignores it if it dies. It runs only while the enabled file in carview_root() exists.
The car's browser refuses private addresses, so the car joins the comma's own hotspot and opens http://3.3.3.3:8099
(see Hotspot).
"""
import json
import os
@@ -35,8 +38,10 @@ STATIC_DIR = Path(__file__).parent / "static"
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"),
"/test": ("test.html", "text/html; charset=utf-8"),
"/garage": ("garage.html", "text/html; charset=utf-8"),
"/cars.js": ("cars.js", "text/javascript; charset=utf-8"),
"/view.js": ("view.js", "text/javascript; charset=utf-8"),
}
@@ -52,12 +57,63 @@ def stats_path() -> Path:
return carview_root() / "stats.jsonl"
SERVICES = ['carState', 'selfdriveState', 'selfdriveStateSP', 'modelV2', 'radarState', 'longitudinalPlanSP',
'navInstruction', 'modelDataV2SP']
def _r(xs, places: int = 2) -> list[float]:
return [round(float(v), places) for v in xs]
def view_state(sm) -> dict:
"""What the page draws, from the latest messages: the car, the model's road and path, leads, alerts and cues."""
cs, ss, sp = sm['carState'], sm['selfdriveState'], sm['selfdriveStateSP']
state = {
'vEgo': round(cs.vEgo, 2), 'gear': str(cs.gearShifter), 'standstill': cs.standstill,
'vCruise': round(cs.vCruiseCluster, 1), 'leftBlinker': cs.leftBlinker, 'rightBlinker': cs.rightBlinker,
'leftBlindspot': cs.leftBlindspot, 'rightBlindspot': cs.rightBlindspot, 'steeringAngle': round(cs.steeringAngleDeg, 1),
'engaged': ss.enabled, 'mads': sp.mads.enabled, 'state': str(ss.state),
'alert': ss.alertText1, 'alert2': ss.alertText2, 'alertStatus': str(ss.alertStatus),
'carStateAlive': sm.alive['carState'],
}
# the model's road in its own frame: x forward, y right, z down, metres from the camera
m = sm['modelV2']
if sm.alive['modelV2'] and len(m.position.x):
state['model'] = {
'path': [_r(m.position.x), _r(m.position.y), _r(m.position.z)],
'lineX': _r(m.laneLines[0].x, 1) if len(m.laneLines) else [],
'lines': [[_r(line.y), _r(line.z)] for line in m.laneLines],
'lineProbs': _r(m.laneLineProbs),
'edges': [[_r(edge.y), _r(edge.z)] for edge in m.roadEdges],
'edgeStds': _r(m.roadEdgeStds),
}
# leads: dRel from our front bumper, yRel positive to the left
rs = sm['radarState']
state['leads'] = [{'d': round(lead.dRel, 1), 'y': round(lead.yRel, 2), 'v': round(lead.vLead, 1), 'a': round(lead.aLeadK, 2)}
for lead in (rs.leadOne, rs.leadTwo) if sm.alive['radarState'] and lead.present]
resolver = sm['longitudinalPlanSP'].speedLimit.resolver
state['speedLimit'] = round(resolver.speedLimit, 2) if resolver.speedLimitValid else 0.
nav = sm['navInstruction']
if sm.alive['navInstruction'] and nav.maneuverPrimaryText:
state['nav'] = {'primary': nav.maneuverPrimaryText, 'secondary': nav.maneuverSecondaryText, 'distance': round(nav.maneuverDistance),
'type': nav.maneuverType, 'modifier': nav.maneuverModifier, 'timeRemaining': round(nav.timeRemaining),
'distanceRemaining': round(nav.distanceRemaining)}
md = sm['modelDataV2SP']
state['turn'] = {'direction': str(md.laneTurnDirection), 'action': round(md.actionCurvature, 4), 'plan': round(md.turnPlanCurvature, 4)}
return state
class CarSnapshot:
"""The latest car state as a plain dict, refreshed from messaging on a background thread."""
"""The latest view state as a plain dict, refreshed from messaging on a background thread."""
def __init__(self):
from openpilot.cereal import messaging
self.sm = messaging.SubMaster(['carState', 'selfdriveState', 'selfdriveStateSP'])
self.sm = messaging.SubMaster(SERVICES)
self.lock = threading.Lock()
self.state: dict = {}
threading.Thread(target=self._run, daemon=True).start()
@@ -65,13 +121,11 @@ class CarSnapshot:
def _run(self) -> None:
while True:
self.sm.update(0)
cs, ss, sp = self.sm['carState'], self.sm['selfdriveState'], self.sm['selfdriveStateSP']
state = {
'vEgo': round(cs.vEgo, 2), 'gear': str(cs.gearShifter), 'standstill': cs.standstill,
'vCruise': round(cs.vCruiseCluster, 1), 'leftBlinker': cs.leftBlinker, 'rightBlinker': cs.rightBlinker,
'engaged': ss.enabled, 'mads': sp.mads.enabled, 'state': str(ss.state), 'alert': ss.alertText1,
'carStateAlive': self.sm.alive['carState'],
}
try:
state = view_state(self.sm)
except Exception:
cloudlog.exception("carviewd: building the view state failed")
state = {}
with self.lock:
self.state = state
time.sleep(1. / SEND_HZ)
@@ -81,6 +135,62 @@ class CarSnapshot:
return dict(self.state)
TETHER_ADDRESS = "192.168.43.1" # the comma's own hotspot
CAR_ADDRESS = "3.3.3.3" # the car's browser refuses private addresses, so on the comma's hotspot the page is also here
HOTSPOT_CHECK_S = 5.
class Hotspot:
"""While the comma's hotspot is on: answer at CAR_ADDRESS, and share the comma's internet with the hotspot.
NetworkManager tries to share it itself, but with an iptables that this kernel can't run, so the hotspot has no
internet and a car joining it drops it again. iptables-legacy works. Off the hotspot, CAR_ADDRESS is taken away again so
it never hides the real host of that address.
"""
def __init__(self, run: Callable[[list[str]], tuple[int, str]]):
self.run = run
def tethering(self) -> bool:
return f"inet {TETHER_ADDRESS}/" in self.run(["ip", "-4", "-o", "addr", "show"])[1]
def has_car_address(self) -> bool:
return f"inet {CAR_ADDRESS}/32" in self.run(["ip", "-4", "-o", "addr", "show", "dev", "lo"])[1]
def uplink(self) -> str | None:
words = self.run(["ip", "route", "show", "default"])[1].split()
return words[words.index("dev") + 1] if "dev" in words[:-1] else None
def update(self) -> None:
on = self.tethering()
if on and not self.has_car_address():
self.run(["sudo", "-n", "ip", "addr", "add", f"{CAR_ADDRESS}/32", "dev", "lo"])
elif not on and self.has_car_address():
self.run(["sudo", "-n", "ip", "addr", "del", f"{CAR_ADDRESS}/32", "dev", "lo"])
if on and (dev := self.uplink()):
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])
def run_command(cmd: list[str]) -> tuple[int, str]:
import subprocess
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return p.returncode, p.stdout
except (OSError, subprocess.TimeoutExpired):
return 1, ""
def keep_hotspot(hotspot: Hotspot) -> None:
while True:
try:
hotspot.update()
except Exception:
cloudlog.exception("carviewd: hotspot setup failed")
time.sleep(HOTSPOT_CHECK_S)
def wall_ms() -> int:
# wall clock on purpose: reports are lined up afterwards with the drive's own logs
return int(time.time() * 1000) # noqa: TID251
@@ -164,7 +274,8 @@ def make_handler(snapshot: Callable[[], dict], log_path: Path, send_hz: float =
self._send(400, b"bad json", "text/plain")
return
server_ms = wall_ms()
append_report(log_path, {'serverTime': server_ms, 'client': self.client_address[0], 'car': snapshot(), 'page': page})
car = {k: v for k, v in snapshot().items() if k != 'model'} # the road geometry would make the log 10x bigger
append_report(log_path, {'serverTime': server_ms, 'client': self.client_address[0], 'car': car, 'page': page})
self._send(200, json.dumps({'serverTime': server_ms}).encode(), "application/json")
return Handler
@@ -185,7 +296,10 @@ def make_server(snapshot: Callable[[], dict], log_path: Path, host: str = "0.0.0
def main() -> None:
from openpilot.common.hardware import COMMA_HARDWARE
os.nice(15) # well below the driving processes
if COMMA_HARDWARE:
threading.Thread(target=keep_hotspot, args=(Hotspot(run_command),), daemon=True).start()
server = make_server(CarSnapshot(), stats_path())
cloudlog.warning(f"carviewd serving on port {PORT}")
server.serve_forever()
@@ -3,43 +3,65 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>carview test</title>
<title>carview</title>
<style>
:root {
--sky: #dcdde0; --ink: #1d1f22; --muted: #6b7076; --card: rgba(255, 255, 255, 0.92); --line: #e3e4e7;
--ok: #1f9d55; --bad: #d64545; --wait: #b7791f; --blue: #3e6ae1;
--sky: #dcdde0; --ink: #1d1f22; --muted: #6b7076; --faint: #a3a7ad; --card: rgba(255, 255, 255, 0.9);
--blue: #3e6ae1; --green: #2fb344; --amber: #c27c0e; --red: #c8312f;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--sky); color: var(--ink);
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-user-select: none; user-select: none; }
#scene { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; }
#hud { position: fixed; top: 24px; left: 32px; }
#gears { letter-spacing: 0.35em; font-size: 18px; color: #a3a7ad; font-weight: 600; }
#hud { position: fixed; top: 20px; left: 28px; }
#gears { letter-spacing: 0.35em; font-size: 17px; color: var(--faint); font-weight: 600; }
#gears b { color: var(--ink); }
#speed { font-size: 88px; font-weight: 300; line-height: 1; margin-top: 6px; font-variant-numeric: tabular-nums; }
#unit { font-size: 16px; color: var(--muted); letter-spacing: 0.1em; }
#setRow { display: flex; gap: 12px; align-items: center; margin-top: 12px; }
#max { font-size: 26px; color: var(--blue); font-weight: 500; font-variant-numeric: tabular-nums; }
#max small { font-size: 13px; display: block; letter-spacing: 0.1em; }
.pill { font-size: 14px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: #c9ccd1; color: #fff; }
.pill.on { background: var(--ok); } .pill.lat { background: var(--blue); }
#clock { position: fixed; top: 26px; right: 32px; font-size: 22px; font-variant-numeric: tabular-nums; color: var(--muted); }
#speedRow { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
#speed { font-size: 84px; font-weight: 300; line-height: 1; font-variant-numeric: tabular-nums; min-width: 1.2em; }
.blinker { width: 26px; height: 26px; opacity: 0.14; transition: opacity 0.08s; }
.blinker.on { opacity: 1; }
#unit { font-size: 15px; color: var(--muted); letter-spacing: 0.12em; margin-left: 40px; }
#setRow { display: flex; gap: 14px; align-items: center; margin-top: 12px; }
#max { font-size: 26px; font-weight: 500; font-variant-numeric: tabular-nums; color: var(--muted); text-align: center; }
#max small { font-size: 12px; display: block; letter-spacing: 0.12em; }
#max.on { color: var(--blue); }
#limit { display: none; width: 50px; border: 2px solid var(--ink); border-radius: 7px; background: #fff; text-align: center;
padding: 3px 0 2px; line-height: 1; }
#limit small { display: block; font-size: 8px; font-weight: 700; letter-spacing: 0.04em; }
#limit b { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
#wheel { width: 30px; height: 30px; color: var(--faint); }
#wheel.steer { color: var(--blue); } #wheel.on { color: var(--blue); }
#wheel.on circle.fill { fill: currentColor; fill-opacity: 0.16; }
#panel { position: fixed; left: 24px; bottom: 24px; width: min(440px, calc(100vw - 48px)); background: var(--card);
border-radius: 16px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); padding: 16px 18px; }
#panel h1 { font-size: 17px; margin: 0 0 4px; display: flex; justify-content: space-between; align-items: center; }
#beat { width: 56px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
#beatDot { width: 12px; height: 6px; border-radius: 3px; background: var(--blue); }
.row { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: baseline; padding: 7px 0;
border-top: 1px solid var(--line); font-size: 16px; }
.row:first-of-type { border-top: 0; }
.mark { font-weight: 700; } .ok .mark { color: var(--ok); } .bad .mark { color: var(--bad); } .wait .mark { color: var(--wait); }
.val { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
#renderer { font-size: 12px; color: var(--muted); margin: 2px 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#panel button { margin-top: 10px; width: 100%; font-size: 16px; padding: 12px; border-radius: 10px; border: 0;
background: var(--ink); color: #fff; font-weight: 600; }
#panel p { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
#nav { display: none; position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: var(--card);
border-radius: 14px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1); padding: 10px 18px 10px 12px; align-items: center; gap: 12px; max-width: 46vw; }
#navArrow { width: 38px; height: 38px; flex: none; }
#navDist { font-size: 24px; font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; }
#navStreet { font-size: 14px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#topRight { position: fixed; top: 20px; right: 24px; text-align: right; }
#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;
max-width: calc(100vw - 460px); min-width: 220px; border-radius: 14px; padding: 12px 20px; color: #fff; text-align: center; background: rgba(29, 31, 34, 0.9);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); }
#alert.userPrompt { background: var(--amber); } #alert.critical { background: var(--red); }
#alert b { display: block; font-size: 20px; font-weight: 600; }
#alert span { display: block; font-size: 15px; opacity: 0.9; margin-top: 3px; }
#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>
@@ -47,243 +69,201 @@
<div id="hud">
<div id="gears">P R N D</div>
<div id="speed">--</div>
<div id="speedRow">
<svg class="blinker" id="blinkL" viewBox="0 0 24 24"><path fill="#2fb344" d="M10 4 2 12l8 8v-5h12V9H10z"/></svg>
<div id="speed">--</div>
<svg class="blinker" id="blinkR" viewBox="0 0 24 24"><path fill="#2fb344" d="M14 4l8 8-8 8v-5H2V9h12z"/></svg>
</div>
<div id="unit">MPH</div>
<div id="setRow"><div id="max">--<small>MAX</small></div><span id="engaged" class="pill">off</span></div>
</div>
<div id="clock"></div>
<div id="panel">
<h1>carview test <span id="beat"><div id="beatDot"></div></span></h1>
<div id="renderer">checking WebGL...</div>
<div class="row wait" id="r-data"><span class="mark">…</span><span>Live data from the comma</span><span class="val"></span></div>
<div class="row wait" id="r-draw"><span class="mark">…</span><span>Drawing</span><span class="val"></span></div>
<div class="row wait" id="r-webgl"><span class="mark">…</span><span>WebGL 2</span><span class="val"></span></div>
<div class="row wait" id="r-three"><span class="mark">…</span><span>three.js</span><span class="val"></span></div>
<div class="row wait" id="r-report"><span class="mark">…</span><span>Reports to the comma</span><span class="val"></span></div>
<button id="fullscreen">Try fullscreen</button>
<p>Leave this open and drive. Every 2 s the page tells the comma what it measured.</p>
<div id="setRow">
<svg id="wheel" viewBox="0 0 32 32" aria-label="steering"><circle class="fill" cx="16" cy="16" r="13" fill="none"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="currentColor" stroke-width="2.6"/>
<path d="M3.5 14.5c4-1.6 8.2-2.4 12.5-2.4s8.5.8 12.5 2.4M16 18v10.5M12.5 18.5 4.6 22.6M19.5 18.5l7.9 4.1" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>
<div id="max">--<small>MAX</small></div>
<div id="limit"><small>SPEED<br>LIMIT</small><b>--</b></div>
</div>
</div>
<script>
// ---- what the page measures, reported to the comma every REPORT_S
<div id="nav"><svg id="navArrow" viewBox="0 0 40 40"><g id="navArrowG" transform="rotate(0 20 20)">
<path d="M20 5 9 17h7v18h8V17h7z" fill="#1d1f22"/></g></svg>
<div><div id="navDist"></div><div id="navStreet"></div></div></div>
<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>
</div>
<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, reportOk: null,
webgl1: false, webgl2: false, renderer: '', three: 'loading', threeError: '', mode: 'none' };
let car = { vEgo: 0, gear: 'unknown', vCruise: 0, engaged: false, mads: false };
let fps = 0, fpsMin = 0;
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;
function setRow(id, state, text) {
const el = document.getElementById(id);
el.className = 'row ' + state;
el.querySelector('.mark').textContent = state === 'ok' ? '✓' : state === 'bad' ? '✗' : '…';
el.querySelector('.val').textContent = text;
}
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); }
(function detectWebGL() {
const c = document.createElement('canvas');
const g2 = c.getContext('webgl2');
m.webgl2 = !!g2;
const g = g2 || c.getContext('webgl');
m.webgl1 = !!(g2 || document.createElement('canvas').getContext('webgl'));
if (g) {
const info = g.getExtension('WEBGL_debug_renderer_info');
m.renderer = info ? g.getParameter(info.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER);
}
document.getElementById('renderer').textContent = m.renderer || 'no WebGL renderer';
setRow('r-webgl', m.webgl2 ? 'ok' : 'bad', m.webgl2 ? 'yes' : (m.webgl1 ? 'only WebGL 1' : 'no'));
})();
// ---- live data: server-sent events from the comma
// ---- live data from the comma
const es = new EventSource('/events');
let eventsThisWindow = 0;
es.onmessage = (e) => {
const now = performance.now();
if (m.lastEvent) m.eventGapMax = Math.max(m.eventGapMax, now - m.lastEvent);
m.lastEvent = now;
m.events++; eventsThisWindow++;
try { car = JSON.parse(e.data); } catch (err) { /* keep the last good state */ }
m.lastEvent = now; m.events++; eventsThisWindow++;
try { car = JSON.parse(e.data); } catch (err) { return; }
if (view) view.update(car);
};
// ---- the scene: three.js when it loads, a flat canvas otherwise
const canvas = document.getElementById('scene');
let drawScene = () => {};
let distance = 0, vSmooth = 0;
let ss = null, leadCar = null; // the supersampler and the car ahead, once three.js has loaded
async function startThree() {
const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js');
const { buildCar, studioEnvironment, supersampler } = await import('/cars.js');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = 1.0;
ss = supersampler(renderer);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdcdde0);
scene.fog = new THREE.Fog(0xdcdde0, 45, 170);
scene.environment = studioEnvironment(THREE, renderer);
scene.environmentIntensity = 0.75;
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 400);
camera.position.set(0, 6.5, 15);
camera.lookAt(0, 0, -26);
scene.add(new THREE.HemisphereLight(0xffffff, 0xa9abaf, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(-5, 9, 6);
scene.add(sun);
const flat = (w, l, color, x, z, y) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, l), new THREE.MeshStandardMaterial({ color, roughness: 1 }));
mesh.rotation.x = -Math.PI / 2; mesh.position.set(x, y, z); scene.add(mesh); return mesh;
};
flat(400, 400, 0xbfc2c6, 0, -150, 0);
flat(11.2, 400, 0xf5f5f6, 0, -180, 0.01);
flat(0.15, 400, 0xd9b44a, -5.4, -180, 0.02); // yellow edge, left
flat(0.15, 400, 0xb8bbc0, 5.4, -180, 0.02); // white edge, right
const path = flat(1.1, 58, 0x3e6ae1, 0, -31, 0.03);
path.material.transparent = true; path.material.opacity = 0.9;
// lane dashes: one instanced mesh, moved by the distance driven
const DASH_EVERY = 12, DASHES = 34, LANES = [-1.8, 1.8];
const dashes = new THREE.InstancedMesh(new THREE.BoxGeometry(0.15, 0.01, 3), new THREE.MeshStandardMaterial({ color: 0xa9adb3, roughness: 1 }),
DASHES * LANES.length);
scene.add(dashes);
const tmp = new THREE.Object3D();
const place = (type, paint, x, z) => { const c = buildCar(THREE, type, paint); c.position.set(x, 0, z); scene.add(c); return c; };
place('crossover', 'ego', 0, 0);
const lead = place('crossover', 'clay', 0, -34);
place('suv', 'clay', -3.6, -58);
place('sedan', 'clay', 3.6, -22);
place('pickup', 'clay', -3.6, -96);
leadCar = lead;
const resize = () => {
renderer.setSize(window.innerWidth, window.innerHeight, false);
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
};
window.addEventListener('resize', resize); resize();
drawScene = (now) => {
ss.tick(now);
let i = 0;
for (const x of LANES) {
for (let k = 0; k < DASHES; k++) {
tmp.position.set(x, 0.02, 20 - k * DASH_EVERY + (distance % DASH_EVERY));
tmp.updateMatrix(); dashes.setMatrixAt(i++, tmp.matrix);
}
}
dashes.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
m.three = 'loaded'; m.mode = 'three';
}
function startFlat() {
const ctx = canvas.getContext('2d');
m.mode = ctx ? 'canvas2d' : 'none';
drawScene = () => {
const w = window.innerWidth, h = window.innerHeight;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
ctx.fillStyle = '#dcdde0'; ctx.fillRect(0, 0, w, h);
const hz = h * 0.42, cx = w / 2;
ctx.fillStyle = '#f5f5f6';
ctx.beginPath(); ctx.moveTo(cx - 20, hz); ctx.lineTo(cx + 20, hz); ctx.lineTo(cx + w * 0.45, h); ctx.lineTo(cx - w * 0.45, h); ctx.fill();
ctx.fillStyle = '#a9adb3';
for (let k = 0; k < 14; k++) {
const z = ((k * 12 - (distance % 12)) + 168) % 168 + 4; // metres ahead
const y = hz + (h - hz) * (6 / z);
for (const s of [-1, 1]) {
const x = cx + s * (w * 0.15) * (6 / z);
ctx.fillRect(x - 2, y, 4, Math.max(2, 40 * (6 / z)));
}
}
};
}
if (new URLSearchParams(location.search).has('flat')) { // ?flat: skip three.js, to compare a plain canvas
m.three = 'skipped'; startFlat();
} else {
startThree().catch((err) => { m.three = 'failed'; m.threeError = String(err).slice(0, 200); startFlat(); });
}
// ---- the frame loop
const beatDot = document.getElementById('beatDot');
let lastFrame = 0, minThisWindow = Infinity;
function frame(now) {
if (lastFrame) {
const dt = Math.min((now - lastFrame) / 1000, 0.25);
m.frameGapMax = Math.max(m.frameGapMax, now - lastFrame);
vSmooth += (car.vEgo - vSmooth) * Math.min(1, dt * 4); // the stream is 20 Hz, the screen faster
distance += vSmooth * dt;
// ---- the scene
(async () => {
try {
const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js');
const { createView } = await import('/view.js');
view = createView(THREE, $('scene'));
view.update(car);
m.three = 'loaded';
const g = document.createElement('canvas').getContext('webgl');
const info = g && g.getExtension('WEBGL_debug_renderer_info');
m.renderer = info ? g.getParameter(info.UNMASKED_RENDERER_WEBGL) : '';
} catch (err) {
m.three = 'failed'; m.threeError = String(err).slice(0, 200);
$('status').textContent = "Couldn't load the 3D view: " + m.threeError; $('status').style.display = 'block';
}
lastFrame = now; m.frames++;
beatDot.style.transform = `translateX(${(now / 25) % 44}px)`;
if (leadCar) leadCar.userData.setBraking(Math.floor(now / 3000) % 3 === 0); // the tail lights blink for the test
drawScene(now);
requestAnimationFrame(frame);
}
})();
// frames counted by the clock, not by the frame loop, so a browser that stops drawing reads 0 instead of its last rate
let hudFrames = 0, hudTime = performance.now();
function measureFps() {
const now = performance.now();
fps = Math.round((m.frames - hudFrames) * 1000 / (now - hudTime));
minThisWindow = Math.min(minThisWindow, fps);
hudFrames = m.frames; hudTime = now;
let last = 0;
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);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ---- HUD and checklist, once a second is plenty
// ---- HUD, a few times a second
const GEARS = { park: 'P', reverse: 'R', neutral: 'N', drive: 'D' };
function updateHud() {
measureFps();
const gear = GEARS[car.gear] || '';
document.getElementById('gears').innerHTML = ['P', 'R', 'N', 'D'].map(g => g === gear ? `<b>${g}</b>` : g).join(' ');
document.getElementById('speed').textContent = Math.round(car.vEgo * 2.23694);
const max = car.vCruise > 0 && car.vCruise < 250 ? Math.round(car.vCruise * 0.621371) : '--';
document.getElementById('max').innerHTML = `${max}<small>MAX</small>`;
const pill = document.getElementById('engaged');
pill.className = 'pill' + (car.engaged ? ' on' : car.mads ? ' lat' : '');
pill.textContent = car.engaged ? 'openpilot' : car.mads ? 'steering' : 'off';
document.getElementById('clock').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const since = m.lastEvent ? performance.now() - m.lastEvent : Infinity;
setRow('r-data', since < 1000 && eventsThisWindow >= 10 ? 'ok' : since < 3000 ? 'wait' : 'bad',
m.lastEvent ? `${eventsThisWindow}/s, gap ${Math.round(m.eventGapMax)} ms` : 'waiting');
eventsThisWindow = 0;
setRow('r-draw', fps >= 30 ? 'ok' : fps > 0 ? 'wait' : 'bad', `${fps} fps (${m.mode}${ss ? `, ${ss.ratio}x` : ''})`);
setRow('r-three', m.three === 'loaded' ? 'ok' : m.three === 'failed' ? 'bad' : 'wait', m.three);
if (m.reportOk !== null) setRow('r-report', m.reportOk ? 'ok' : 'bad', m.reportOk ? `${m.rtt} ms round trip` : 'failed');
const MPH = 2.23694;
function fmtDistance(m_) { const mi = m_ / 1609.34; return mi < 0.1 ? `${Math.max(10, Math.round(m_ * 3.28084 / 10) * 10)} ft` : `${mi < 10 ? mi.toFixed(1) : Math.round(mi)} mi`; }
function arrowAngle(type, modifier) {
if (type === 'arrive') return 0;
const mod = (modifier || '').toLowerCase();
const a = { 'sharp left': -135, 'left': -90, 'slight left': -45, 'straight': 0, 'slight right': 45, 'right': 90, 'sharp right': 135, 'uturn': 180 }[mod];
return a ?? 0;
}
setInterval(updateHud, 1000);
function hud() {
const gear = GEARS[car.gear] || '';
$('gears').innerHTML = ['P', 'R', 'N', 'D'].map(g => (g === gear ? `<b>${g}</b>` : g)).join(' ');
$('speed').textContent = car.carStateAlive ? Math.round((car.vEgo || 0) * MPH) : '--';
const set = car.vCruise > 0 && car.vCruise < 250 ? Math.round(car.vCruise * 0.621371) : null;
$('max').innerHTML = `${set ?? '--'}<small>MAX</small>`;
$('max').className = car.engaged ? 'on' : '';
$('wheel').setAttribute('class', car.engaged ? 'on' : car.mads ? 'steer' : '');
const limit = car.speedLimit > 0 ? Math.round(car.speedLimit * MPH) : 0;
$('limit').style.display = limit ? 'block' : 'none';
$('limit').querySelector('b').textContent = limit || '';
const blinkOn = Math.floor(performance.now() / 333) % 2 === 0;
$('blinkL').classList.toggle('on', !!car.leftBlinker && blinkOn);
$('blinkR').classList.toggle('on', !!car.rightBlinker && blinkOn);
// ---- report to the comma
const nav = car.nav;
$('nav').style.display = nav ? 'flex' : 'none';
if (nav) {
$('navArrowG').setAttribute('transform', `rotate(${arrowAngle(nav.type, nav.modifier)} 20 20)`);
$('navDist').textContent = fmtDistance(nav.distance);
$('navStreet').textContent = nav.primary;
const arrive = new Date(Date.now() + nav.timeRemaining * 1000);
$('eta').style.display = 'block';
$('eta').textContent = `${arrive.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} · ${Math.round(nav.timeRemaining / 60)} min · ${fmtDistance(nav.distanceRemaining)}`;
} else $('eta').style.display = 'none';
const al = $('alert');
al.style.display = car.alert ? 'block' : 'none';
al.style.top = nav ? `${$('nav').offsetHeight + 32}px` : '20px'; // under the nav card when there is one
al.className = car.alertStatus || '';
al.querySelector('b').textContent = car.alert || '';
al.querySelector('span').textContent = car.alert2 || '';
$('clock').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const quiet = !m.lastEvent || performance.now() - m.lastEvent > 1500;
if (m.three !== 'failed') { $('status').textContent = 'Waiting for the comma…'; $('status').style.display = quiet ? 'block' : 'none'; }
}
setInterval(hud, 166);
// frames counted by the clock, so a browser that stops drawing reads 0 instead of its last rate
let fpsFrames = 0, fpsTime = performance.now();
setInterval(() => {
const now = performance.now();
fps = Math.round((m.frames - fpsFrames) * 1000 / (now - fpsTime));
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();
};
// ---- report to the comma what this browser managed
async function report() {
const body = {
uptimeS: Math.round((performance.now() - started) / 1000), fps, fpsMin: Number.isFinite(minThisWindow) ? Math.round(minThisWindow) : null,
frames: m.frames, frameGapMaxMs: Math.round(m.frameGapMax), events: m.events, eventGapMaxMs: Math.round(m.eventGapMax),
eventSource: es.readyState, carSeen: { vEgo: car.vEgo, gear: car.gear, n: car.n },
webgl1: m.webgl1, webgl2: m.webgl2, renderer: m.renderer, three: m.three, threeError: m.threeError, mode: m.mode,
visibility: document.visibilityState, w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio,
rttMs: m.rtt, pixelRatio: ss ? ss.ratio : null, ua: navigator.userAgent, url: location.href,
page: embedded ? 'view-embed' : '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; minThisWindow = Infinity;
m.frameGapMax = 0; m.eventGapMax = 0; fpsMin = Infinity;
const t0 = performance.now();
try {
const r = await fetch('/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
m.reportOk = r.ok; m.rtt = Math.round(performance.now() - t0);
} catch (err) {
m.reportOk = false;
}
if (r.ok) m.rtt = Math.round(performance.now() - t0);
} catch (err) { /* the next one will tell */ }
}
setInterval(report, REPORT_S * 1000);
document.addEventListener('visibilitychange', report);
document.getElementById('fullscreen').onclick = () => {
// the car opens YouTube's redirect page chromeless, and the page it forwards to stays that way
location.href = 'https://www.youtube.com/redirect?q=' + encodeURIComponent(location.href);
};
</script>
</body>
</html>
@@ -0,0 +1,290 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>carview test</title>
<style>
:root {
--sky: #dcdde0; --ink: #1d1f22; --muted: #6b7076; --card: rgba(255, 255, 255, 0.92); --line: #e3e4e7;
--ok: #1f9d55; --bad: #d64545; --wait: #b7791f; --blue: #3e6ae1;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--sky); color: var(--ink);
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-user-select: none; user-select: none; }
#scene { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; }
#hud { position: fixed; top: 24px; left: 32px; }
#gears { letter-spacing: 0.35em; font-size: 18px; color: #a3a7ad; font-weight: 600; }
#gears b { color: var(--ink); }
#speed { font-size: 88px; font-weight: 300; line-height: 1; margin-top: 6px; font-variant-numeric: tabular-nums; }
#unit { font-size: 16px; color: var(--muted); letter-spacing: 0.1em; }
#setRow { display: flex; gap: 12px; align-items: center; margin-top: 12px; }
#max { font-size: 26px; color: var(--blue); font-weight: 500; font-variant-numeric: tabular-nums; }
#max small { font-size: 13px; display: block; letter-spacing: 0.1em; }
.pill { font-size: 14px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: #c9ccd1; color: #fff; }
.pill.on { background: var(--ok); } .pill.lat { background: var(--blue); }
#clock { position: fixed; top: 26px; right: 32px; font-size: 22px; font-variant-numeric: tabular-nums; color: var(--muted); }
#panel { position: fixed; left: 24px; bottom: 24px; width: min(440px, calc(100vw - 48px)); background: var(--card);
border-radius: 16px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); padding: 16px 18px; }
#panel h1 { font-size: 17px; margin: 0 0 4px; display: flex; justify-content: space-between; align-items: center; }
#beat { width: 56px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
#beatDot { width: 12px; height: 6px; border-radius: 3px; background: var(--blue); }
.row { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: baseline; padding: 7px 0;
border-top: 1px solid var(--line); font-size: 16px; }
.row:first-of-type { border-top: 0; }
.mark { font-weight: 700; } .ok .mark { color: var(--ok); } .bad .mark { color: var(--bad); } .wait .mark { color: var(--wait); }
.val { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
#renderer { font-size: 12px; color: var(--muted); margin: 2px 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#panel button { margin-top: 10px; width: 100%; font-size: 16px; padding: 12px; border-radius: 10px; border: 0;
background: var(--ink); color: #fff; font-weight: 600; }
#panel p { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<canvas id="scene"></canvas>
<div id="hud">
<div id="gears">P R N D</div>
<div id="speed">--</div>
<div id="unit">MPH</div>
<div id="setRow"><div id="max">--<small>MAX</small></div><span id="engaged" class="pill">off</span></div>
</div>
<div id="clock"></div>
<div id="panel">
<h1>carview test <span id="beat"><div id="beatDot"></div></span></h1>
<div id="renderer">checking WebGL...</div>
<div class="row wait" id="r-data"><span class="mark">…</span><span>Live data from the comma</span><span class="val"></span></div>
<div class="row wait" id="r-draw"><span class="mark">…</span><span>Drawing</span><span class="val"></span></div>
<div class="row wait" id="r-webgl"><span class="mark">…</span><span>WebGL 2</span><span class="val"></span></div>
<div class="row wait" id="r-three"><span class="mark">…</span><span>three.js</span><span class="val"></span></div>
<div class="row wait" id="r-report"><span class="mark">…</span><span>Reports to the comma</span><span class="val"></span></div>
<button id="fullscreen">Try full screen</button>
<p>Leave this open and drive. Every 2 s the page tells the comma what it measured.</p>
</div>
<script>
// ---- what the page measures, reported to the comma every REPORT_S
const REPORT_S = 2;
const started = performance.now();
const m = { frames: 0, frameGapMax: 0, events: 0, eventGapMax: 0, lastEvent: 0, rtt: null, reportOk: null,
webgl1: false, webgl2: false, renderer: '', three: 'loading', threeError: '', mode: 'none' };
let car = { vEgo: 0, gear: 'unknown', vCruise: 0, engaged: false, mads: false };
let fps = 0, fpsMin = 0;
function setRow(id, state, text) {
const el = document.getElementById(id);
el.className = 'row ' + state;
el.querySelector('.mark').textContent = state === 'ok' ? '✓' : state === 'bad' ? '✗' : '…';
el.querySelector('.val').textContent = text;
}
(function detectWebGL() {
const c = document.createElement('canvas');
const g2 = c.getContext('webgl2');
m.webgl2 = !!g2;
const g = g2 || c.getContext('webgl');
m.webgl1 = !!(g2 || document.createElement('canvas').getContext('webgl'));
if (g) {
const info = g.getExtension('WEBGL_debug_renderer_info');
m.renderer = info ? g.getParameter(info.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER);
}
document.getElementById('renderer').textContent = m.renderer || 'no WebGL renderer';
setRow('r-webgl', m.webgl2 ? 'ok' : 'bad', m.webgl2 ? 'yes' : (m.webgl1 ? 'only WebGL 1' : 'no'));
})();
// ---- live data: server-sent events from the comma
const es = new EventSource('/events');
let eventsThisWindow = 0;
es.onmessage = (e) => {
const now = performance.now();
if (m.lastEvent) m.eventGapMax = Math.max(m.eventGapMax, now - m.lastEvent);
m.lastEvent = now;
m.events++; eventsThisWindow++;
try { car = JSON.parse(e.data); } catch (err) { /* keep the last good state */ }
};
// ---- the scene: three.js when it loads, a flat canvas otherwise
const canvas = document.getElementById('scene');
let drawScene = () => {};
let distance = 0, vSmooth = 0;
let ss = null, leadCar = null; // the supersampler and the car ahead, once three.js has loaded
async function startThree() {
const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js');
const { buildCar, studioEnvironment, supersampler } = await import('/cars.js');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = 1.0;
ss = supersampler(renderer);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdcdde0);
scene.fog = new THREE.Fog(0xdcdde0, 45, 170);
scene.environment = studioEnvironment(THREE, renderer);
scene.environmentIntensity = 0.75;
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 400);
camera.position.set(0, 6.5, 15);
camera.lookAt(0, 0, -26);
scene.add(new THREE.HemisphereLight(0xffffff, 0xa9abaf, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(-5, 9, 6);
scene.add(sun);
const flat = (w, l, color, x, z, y) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, l), new THREE.MeshStandardMaterial({ color, roughness: 1 }));
mesh.rotation.x = -Math.PI / 2; mesh.position.set(x, y, z); scene.add(mesh); return mesh;
};
flat(400, 400, 0xbfc2c6, 0, -150, 0);
flat(11.2, 400, 0xf5f5f6, 0, -180, 0.01);
flat(0.15, 400, 0xd9b44a, -5.4, -180, 0.02); // yellow edge, left
flat(0.15, 400, 0xb8bbc0, 5.4, -180, 0.02); // white edge, right
const path = flat(1.1, 58, 0x3e6ae1, 0, -31, 0.03);
path.material.transparent = true; path.material.opacity = 0.9;
// lane dashes: one instanced mesh, moved by the distance driven
const DASH_EVERY = 12, DASHES = 34, LANES = [-1.8, 1.8];
const dashes = new THREE.InstancedMesh(new THREE.BoxGeometry(0.15, 0.01, 3), new THREE.MeshStandardMaterial({ color: 0xa9adb3, roughness: 1 }),
DASHES * LANES.length);
scene.add(dashes);
const tmp = new THREE.Object3D();
const place = (type, paint, x, z) => { const c = buildCar(THREE, type, paint); c.position.set(x, 0, z); scene.add(c); return c; };
place('crossover', 'ego', 0, 0);
const lead = place('crossover', 'clay', 0, -34);
place('suv', 'clay', -3.6, -58);
place('sedan', 'clay', 3.6, -22);
place('pickup', 'clay', -3.6, -96);
leadCar = lead;
const resize = () => {
renderer.setSize(window.innerWidth, window.innerHeight, false);
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
};
window.addEventListener('resize', resize); resize();
drawScene = (now) => {
ss.tick(now);
let i = 0;
for (const x of LANES) {
for (let k = 0; k < DASHES; k++) {
tmp.position.set(x, 0.02, 20 - k * DASH_EVERY + (distance % DASH_EVERY));
tmp.updateMatrix(); dashes.setMatrixAt(i++, tmp.matrix);
}
}
dashes.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
m.three = 'loaded'; m.mode = 'three';
}
function startFlat() {
const ctx = canvas.getContext('2d');
m.mode = ctx ? 'canvas2d' : 'none';
drawScene = () => {
const w = window.innerWidth, h = window.innerHeight;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
ctx.fillStyle = '#dcdde0'; ctx.fillRect(0, 0, w, h);
const hz = h * 0.42, cx = w / 2;
ctx.fillStyle = '#f5f5f6';
ctx.beginPath(); ctx.moveTo(cx - 20, hz); ctx.lineTo(cx + 20, hz); ctx.lineTo(cx + w * 0.45, h); ctx.lineTo(cx - w * 0.45, h); ctx.fill();
ctx.fillStyle = '#a9adb3';
for (let k = 0; k < 14; k++) {
const z = ((k * 12 - (distance % 12)) + 168) % 168 + 4; // metres ahead
const y = hz + (h - hz) * (6 / z);
for (const s of [-1, 1]) {
const x = cx + s * (w * 0.15) * (6 / z);
ctx.fillRect(x - 2, y, 4, Math.max(2, 40 * (6 / z)));
}
}
};
}
if (new URLSearchParams(location.search).has('flat')) { // ?flat: skip three.js, to compare a plain canvas
m.three = 'skipped'; startFlat();
} else {
startThree().catch((err) => { m.three = 'failed'; m.threeError = String(err).slice(0, 200); startFlat(); });
}
// ---- the frame loop
const beatDot = document.getElementById('beatDot');
let lastFrame = 0, minThisWindow = Infinity;
function frame(now) {
if (lastFrame) {
const dt = Math.min((now - lastFrame) / 1000, 0.25);
m.frameGapMax = Math.max(m.frameGapMax, now - lastFrame);
vSmooth += (car.vEgo - vSmooth) * Math.min(1, dt * 4); // the stream is 20 Hz, the screen faster
distance += vSmooth * dt;
}
lastFrame = now; m.frames++;
beatDot.style.transform = `translateX(${(now / 25) % 44}px)`;
if (leadCar) leadCar.userData.setBraking(Math.floor(now / 3000) % 3 === 0); // the tail lights blink for the test
drawScene(now);
requestAnimationFrame(frame);
}
// frames counted by the clock, not by the frame loop, so a browser that stops drawing reads 0 instead of its last rate
let hudFrames = 0, hudTime = performance.now();
function measureFps() {
const now = performance.now();
fps = Math.round((m.frames - hudFrames) * 1000 / (now - hudTime));
minThisWindow = Math.min(minThisWindow, fps);
hudFrames = m.frames; hudTime = now;
}
requestAnimationFrame(frame);
// ---- HUD and checklist, once a second is plenty
const GEARS = { park: 'P', reverse: 'R', neutral: 'N', drive: 'D' };
function updateHud() {
measureFps();
const gear = GEARS[car.gear] || '';
document.getElementById('gears').innerHTML = ['P', 'R', 'N', 'D'].map(g => g === gear ? `<b>${g}</b>` : g).join(' ');
document.getElementById('speed').textContent = Math.round(car.vEgo * 2.23694);
const max = car.vCruise > 0 && car.vCruise < 250 ? Math.round(car.vCruise * 0.621371) : '--';
document.getElementById('max').innerHTML = `${max}<small>MAX</small>`;
const pill = document.getElementById('engaged');
pill.className = 'pill' + (car.engaged ? ' on' : car.mads ? ' lat' : '');
pill.textContent = car.engaged ? 'openpilot' : car.mads ? 'steering' : 'off';
document.getElementById('clock').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const since = m.lastEvent ? performance.now() - m.lastEvent : Infinity;
setRow('r-data', since < 1000 && eventsThisWindow >= 10 ? 'ok' : since < 3000 ? 'wait' : 'bad',
m.lastEvent ? `${eventsThisWindow}/s, gap ${Math.round(m.eventGapMax)} ms` : 'waiting');
eventsThisWindow = 0;
setRow('r-draw', fps >= 30 ? 'ok' : fps > 0 ? 'wait' : 'bad', `${fps} fps (${m.mode}${ss ? `, ${ss.ratio}x` : ''})`);
setRow('r-three', m.three === 'loaded' ? 'ok' : m.three === 'failed' ? 'bad' : 'wait', m.three);
if (m.reportOk !== null) setRow('r-report', m.reportOk ? 'ok' : 'bad', m.reportOk ? `${m.rtt} ms round trip` : 'failed');
}
setInterval(updateHud, 1000);
// ---- report to the comma
async function report() {
const body = {
uptimeS: Math.round((performance.now() - started) / 1000), fps, fpsMin: Number.isFinite(minThisWindow) ? Math.round(minThisWindow) : null,
frames: m.frames, frameGapMaxMs: Math.round(m.frameGapMax), events: m.events, eventGapMaxMs: Math.round(m.eventGapMax),
eventSource: es.readyState, carSeen: { vEgo: car.vEgo, gear: car.gear, n: car.n },
webgl1: m.webgl1, webgl2: m.webgl2, renderer: m.renderer, three: m.three, threeError: m.threeError, mode: m.mode,
visibility: document.visibilityState, w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio,
rttMs: m.rtt, pixelRatio: ss ? ss.ratio : null, fullscreenNow: !!document.fullscreenElement, ua: navigator.userAgent, url: location.href,
};
m.frameGapMax = 0; m.eventGapMax = 0; minThisWindow = Infinity;
const t0 = performance.now();
try {
const r = await fetch('/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
m.reportOk = r.ok; m.rtt = Math.round(performance.now() - t0);
} catch (err) {
m.reportOk = false;
}
}
setInterval(report, REPORT_S * 1000);
document.addEventListener('visibilitychange', report);
document.getElementById('fullscreen').onclick = async () => {
// the browser's own full screen; the page's next report says whether it was allowed
try { await document.documentElement.requestFullscreen(); } catch (err) { /* reported below */ }
report();
};
</script>
</body>
</html>
@@ -0,0 +1,257 @@
// carview's driving scene: the model's road, lane lines and planned path, the lead cars and our own car, in three.js.
//
// The model speaks in its own frame, x forward, y right, z down, in metres from the comma's camera. The scene is
// three.js's: x right, y up, forward down -z, so a model point (x, y, z) sits at (y, -z, -x). Everything stays in the
// car's frame, as on the car's own display: our car never moves, the road bends and flows past it.
import { buildCar, studioEnvironment, supersampler } from '/cars.js';
const CAMERA_TO_FRONT = 1.52; // m, the comma sits this far behind the front bumper (radard's RADAR_TO_CAMERA)
const BEHIND = -12; // m of road drawn behind the camera, beyond what the model sees
const ROAD_FAR = 170; // m, the fog has everything past this
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
const LEAD_BRAKING = -1.0; // m/s^2, the lead's tail lights come on below this
const COLORS = {
sky: 0xdcdde0, ground: 0xc4c7cb, road: 0xf4f4f5, line: 0x9ea3a9, blindspot: 0xe0464b,
pathOn: 0x3e6ae1, pathSteer: 0x5b8def, pathOff: 0xa9adb3,
};
// a straight three-lane road for before the first model frame, in the model's frame
function defaultRoad(lineX) {
const flat = (y) => [lineX.map(() => y), lineX.map(() => 0)];
return { lines: [flat(-5.4), flat(-1.8), flat(1.8), flat(5.4)], lineProbs: [0.6, 0.9, 0.9, 0.6], edges: [flat(-7.2), flat(7.2)] };
}
const DEFAULT_X = Array.from({ length: 33 }, (_, i) => 192 * (i / 32) ** 2);
// a polyline in the model's frame, eased toward each new model frame; xs stay fixed, y and z move
class Line {
constructor(xs) { this.setX(xs); }
setX(xs) {
// prepend a straight run behind the camera so lines continue past our own car
this.xs = [BEHIND, ...xs];
this.y = new Float32Array(this.xs.length); this.z = new Float32Array(this.xs.length);
this.ty = new Float32Array(this.xs.length); this.tz = new Float32Array(this.xs.length);
this.fresh = true;
}
target(ys, zs) {
if (ys.length + 1 !== this.xs.length) return;
this.ty[0] = ys[0]; this.tz[0] = zs[0];
for (let i = 0; i < ys.length; i++) { this.ty[i + 1] = ys[i]; this.tz[i + 1] = zs[i]; }
if (this.fresh) { this.y.set(this.ty); this.z.set(this.tz); this.fresh = false; }
}
ease(k) { for (let i = 0; i < this.y.length; i++) { this.y[i] += (this.ty[i] - this.y[i]) * k; this.z[i] += (this.tz[i] - this.z[i]) * k; } }
at(x) { // y and z at model x, linear between samples
const xs = this.xs;
if (x <= xs[0]) return [this.y[0], this.z[0]];
for (let i = 1; i < xs.length; i++) {
if (x <= xs[i]) { const f = (x - xs[i - 1]) / (xs[i] - xs[i - 1]); return [this.y[i - 1] + (this.y[i] - this.y[i - 1]) * f, this.z[i - 1] + (this.z[i] - this.z[i - 1]) * f]; }
}
return [this.y[xs.length - 1], this.z[xs.length - 1]];
}
}
// a flat ribbon along points [[X, Y, Z], ...] in scene space, written into a preallocated geometry
function writeRibbon(geo, pts, width, lift, alphaAt = null) {
const pos = geo.attributes.position.array, col = geo.attributes.color ? geo.attributes.color.array : null;
const n = pts.length;
for (let i = 0; i < n; i++) {
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let tx = b[0] - a[0], tz = b[2] - a[2];
const len = Math.hypot(tx, tz) || 1; tx /= len; tz /= len;
const nx = -tz * width / 2, nz = tx * width / 2; // sideways in the road plane
const [X, Y, Z] = pts[i];
pos.set([X - nx, Y + lift, Z - nz, X + nx, Y + lift, Z + nz], i * 6);
if (col) { const al = alphaAt ? alphaAt(i, n) : 1; col.set([1, 1, 1, al, 1, 1, 1, al], i * 8); }
}
geo.setDrawRange(0, Math.max(0, (n - 1) * 6));
geo.attributes.position.needsUpdate = true;
if (col) geo.attributes.color.needsUpdate = true;
}
// two triangles per step along a strip of [left, right] vertex pairs, wound to face up
const stripIndex = (out, a) => out.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
function ribbonGeometry(THREE, maxPoints, { alpha = false, lit = false } = {}) {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(maxPoints * 2 * 3), 3));
if (alpha) g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(maxPoints * 2 * 4), 4));
if (lit) g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(maxPoints * 2 * 3).map((_, i) => (i % 3 === 1 ? 1 : 0)), 3));
const idx = [];
for (let i = 0; i < maxPoints - 1; i++) stripIndex(idx, 2 * i);
g.setIndex(idx);
return g;
}
// a strip between two lines, the road surface
function writeStrip(geo, left, right, lift) {
const pos = geo.attributes.position.array, n = left.length;
for (let i = 0; i < n; i++) pos.set([left[i][0], left[i][1] + lift, left[i][2], right[i][0], right[i][1] + lift, right[i][2]], i * 6);
geo.setDrawRange(0, (n - 1) * 6);
geo.attributes.position.needsUpdate = true;
}
// meshes whose vertices move every frame: never culled, so their bounds needn't be kept up
const live = (THREE, geo, mat) => { const m = new THREE.Mesh(geo, mat); m.frustumCulled = false; return m; };
const toScene = (x, y, z) => [y, -z, -x];
export function createView(THREE, canvas) {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.NeutralToneMapping;
const ss = supersampler(renderer);
const scene = new THREE.Scene();
scene.background = new THREE.Color(COLORS.sky);
scene.fog = new THREE.Fog(COLORS.sky, 60, ROAD_FAR);
scene.environment = studioEnvironment(THREE, renderer);
scene.environmentIntensity = 0.75;
scene.add(new THREE.HemisphereLight(0xffffff, 0xa9abaf, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.5); sun.position.set(-5, 9, 6); scene.add(sun);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 400);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(800, 800), new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 1 }));
ground.rotation.x = -Math.PI / 2; ground.position.set(0, -0.05, -150); scene.add(ground);
// model state, eased
const lines = [0, 1, 2, 3].map(() => new Line(DEFAULT_X));
const edges = [0, 1].map(() => new Line(DEFAULT_X));
const lineProbs = [0.6, 0.9, 0.9, 0.6], probTarget = [...lineProbs];
let path = null, pathTarget = null; // [[x, y, z], ...] in the model's frame
const road = defaultRoad(DEFAULT_X);
road.lines.forEach((l, i) => lines[i].target(...l));
road.edges.forEach((e, i) => edges[i].target(...e));
// meshes
const roadMat = new THREE.MeshStandardMaterial({ color: COLORS.road, roughness: 1, polygonOffset: true, polygonOffsetFactor: -1 });
const roadGeo = ribbonGeometry(THREE, 40, { lit: true });
scene.add(live(THREE, roadGeo, roadMat));
const lineMats = lines.map(() => new THREE.MeshBasicMaterial({ color: COLORS.line, transparent: true, opacity: 0.8, depthWrite: false }));
const solidGeos = [0, 3].map(() => ribbonGeometry(THREE, 40));
scene.add(live(THREE, solidGeos[0], lineMats[0]), live(THREE, solidGeos[1], lineMats[3]));
const MAX_DASHES = 20, DASH_POINTS = 3;
const dashGeos = [1, 2].map(() => {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(MAX_DASHES * DASH_POINTS * 2 * 3), 3));
const idx = [];
for (let d = 0; d < MAX_DASHES; d++) for (let i = 0; i < DASH_POINTS - 1; i++) stripIndex(idx, (d * DASH_POINTS + i) * 2);
g.setIndex(idx);
return g;
});
scene.add(live(THREE, dashGeos[0], lineMats[1]), live(THREE, dashGeos[1], lineMats[2]));
const pathMat = new THREE.MeshBasicMaterial({ color: COLORS.pathOff, vertexColors: true, transparent: true, depthWrite: false });
const pathGeo = ribbonGeometry(THREE, 34, { alpha: true });
scene.add(live(THREE, pathGeo, pathMat));
const ego = buildCar(THREE, 'crossover', 'ego');
ego.position.set(0, 0, -CAMERA_TO_FRONT + ego.userData.length / 2);
scene.add(ego);
const leadCars = [buildCar(THREE, 'suv', 'clay'), buildCar(THREE, 'sedan', 'clay')];
const leadState = leadCars.map(() => ({ on: false, x: 0, z: 0 }));
leadCars.forEach(c => { c.visible = false; scene.add(c); });
let car = {}, distance = 0, v = 0, camBack = 12;
function resize() {
const w = canvas.clientWidth || window.innerWidth, h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize); resize();
function update(state) {
car = state || {};
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])));
(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]]);
if (!path || path.length !== pathTarget.length) path = pathTarget.map(p => [...p]);
}
}
const sample = (line, x) => { const [y, z] = line.at(x); return toScene(x, y, z); };
function frame(now, dt) {
ss.tick(now);
const k = 1 - Math.exp(-dt / EASE_S);
[...lines, ...edges].forEach(l => l.ease(k));
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;
v += ((car.vEgo || 0) - v) * Math.min(1, dt * 4);
distance += v * dt;
// road surface between the edges, out to the fog
const xs = lines[0].xs.filter(x => x <= ROAD_FAR);
writeStrip(roadGeo, xs.map(x => sample(edges[0], x)), xs.map(x => sample(edges[1], x)), 0.0);
// outer lines solid, the lines either side of our lane dashed and flowing past
[0, 3].forEach((li, j) => writeRibbon(solidGeos[j], xs.map(x => sample(lines[li], x)), LINE_WIDTH, 0.02));
[1, 2].forEach((li, j) => {
const g = dashGeos[j], pos = g.attributes.position.array;
let d = 0;
for (let s = BEHIND - (distance % DASH_EVERY); s < ROAD_FAR && d < MAX_DASHES; s += DASH_EVERY) {
const pts = [];
for (let i = 0; i < DASH_POINTS; i++) pts.push(sample(lines[li], s + DASH * i / (DASH_POINTS - 1)));
for (let i = 0; i < DASH_POINTS; i++) {
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(DASH_POINTS - 1, i + 1)];
let tx = b[0] - a[0], tz = b[2] - a[2]; const len = Math.hypot(tx, tz) || 1; tx /= len; tz /= len;
const nx = -tz * LINE_WIDTH / 2, nz = tx * LINE_WIDTH / 2, [X, Y, Z] = pts[i];
pos.set([X - nx, Y + 0.02, Z - nz, X + nx, Y + 0.02, Z + nz], (d * DASH_POINTS + i) * 6);
}
d++;
}
g.setDrawRange(0, d * (DASH_POINTS - 1) * 6);
g.attributes.position.needsUpdate = true;
});
lineMats.forEach((mat, i) => {
const blind = (i === 1 && car.leftBlindspot) || (i === 2 && car.rightBlindspot);
mat.color.setHex(blind ? COLORS.blindspot : COLORS.line);
mat.opacity = blind ? 1 : Math.max(0.08, Math.min(1, lineProbs[i])) * (i === 0 || i === 3 ? 0.7 : 0.95);
});
// the planned path from our front bumper, fading out ahead
if (path) {
const pts = path.filter(p => p[0] >= CAMERA_TO_FRONT - 0.5 && p[0] <= PATH_FAR).map(p => toScene(...p));
if (pts.length > 1) writeRibbon(pathGeo, pts, PATH_WIDTH, 0.03, (i, n) => 0.9 * (1 - (i / (n - 1)) ** 2));
}
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 || [];
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;
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);
lc.visible = true;
lc.userData.setBraking(lead.a < LEAD_BRAKING);
});
// chase camera: closer in town, further back and higher at speed
camBack += (Math.min(16, 9 + v * 0.3) - camBack) * Math.min(1, dt * 1.5);
camera.position.set(0, 3.6 + camBack * 0.22, ego.position.z + camBack);
camera.lookAt(0, 0, ego.position.z - 26);
renderer.render(scene, camera);
}
return { update, frame, resize, get pixelRatio() { return ss.ratio; } };
}
@@ -40,9 +40,11 @@ def test_serves_the_page(server):
@pytest.mark.parametrize("path,content_type,marker", [
("/cars.js", "text/javascript", b"export function buildCar"),
("/view.js", "text/javascript", b"export function createView"),
("/garage", "text/html", b"import { buildCar"),
("/test", "text/html", b"carview test"),
])
def test_serves_the_car_models_and_the_garage(server, path, content_type, marker):
def test_serves_the_scene_and_the_other_pages(server, path, content_type, marker):
srv, _ = server
_, r = request(srv, "GET", path)
assert r.status == 200
@@ -132,3 +134,106 @@ def test_it_runs_only_while_the_enabled_file_exists(tmp_path, monkeypatch):
flag.touch()
assert process_config.carview(True, None, None) and process_config.carview(False, None, None)
assert any(p.name == "carviewd" for p in process_config.procs)
class FakeSM:
"""The latest message of each service carviewd reads, as SubMaster gives them."""
def __init__(self, alive=True):
from openpilot.cereal import messaging
self.builders = {s: messaging.new_message(s) for s in carviewd.SERVICES}
self.alive = dict.fromkeys(carviewd.SERVICES, alive)
def __getitem__(self, service):
return getattr(self.builders[service], service)
def test_view_state_reads_real_message_fields_with_nothing_set():
# every field it reads has to exist in these message types, or the page gets nothing
state = carviewd.view_state(FakeSM())
assert state['gear'] == 'unknown' and state['leads'] == [] and state['speedLimit'] == 0.
assert 'nav' not in state
def test_view_state_carries_the_road_the_leads_and_the_cues():
sm = FakeSM()
m = sm['modelV2']
xs = [float(i) for i in range(33)]
m.position.x, m.position.y, m.position.z = xs, [0.1] * 33, [0.0] * 33
m.init('laneLines', 4)
for i, line in enumerate(m.laneLines):
line.x, line.y, line.z = xs, [-5.4 + 3.6 * i] * 33, [1.3] * 33
m.laneLineProbs = [0.2, 0.9, 0.9, 0.2]
m.init('roadEdges', 2)
for i, edge in enumerate(m.roadEdges):
edge.x, edge.y, edge.z = xs, [-7.0 + 14 * i] * 33, [1.3] * 33
m.roadEdgeStds = [0.3, 0.4]
lead = sm['radarState'].leadOne
lead.present, lead.dRel, lead.yRel, lead.vLead, lead.aLeadK = True, 30.0, 0.5, 12.0, -1.5
sm['longitudinalPlanSP'].speedLimit.resolver.speedLimit = 29.06
sm['longitudinalPlanSP'].speedLimit.resolver.speedLimitValid = True
nav = sm['navInstruction']
nav.maneuverPrimaryText, nav.maneuverDistance, nav.maneuverModifier = "Marsh St", 1609.0, "right"
state = carviewd.view_state(sm)
assert len(state['model']['path'][0]) == 33 and len(state['model']['lines']) == 4 and len(state['model']['edges']) == 2
assert state['model']['lineProbs'] == [0.2, 0.9, 0.9, 0.2]
assert state['leads'] == [{'d': 30.0, 'y': 0.5, 'v': 12.0, 'a': -1.5}]
assert state['speedLimit'] == 29.06
assert state['nav']['primary'] == "Marsh St" and state['nav']['modifier'] == "right"
json.dumps(state) # the page gets it as JSON
def test_view_state_leaves_out_a_model_that_isnt_running():
assert 'model' not in carviewd.view_state(FakeSM(alive=False))
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"):
self.tethering, self.car_address, self.nat, self.uplink, self.calls = tethering, car_address, nat, uplink, []
def __call__(self, cmd):
self.calls.append(cmd)
if cmd[:4] == ["ip", "-4", "-o", "addr"]:
out = "1: lo inet 127.0.0.1/8 scope host lo\n"
if self.car_address:
out += f"1: lo inet {carviewd.CAR_ADDRESS}/32 scope global lo\n"
if self.tethering and "lo" not in cmd:
out += f"3: wlan0 inet {carviewd.TETHER_ADDRESS}/24 brd 192.168.43.255 scope global wlan0\n"
return 0, out
if cmd[:3] == ["ip", "route", "show"]:
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), ""
return 0, ""
def asked(self, *words):
return [c for c in self.calls if all(w in c for w in words)]
def test_hotspot_on_gets_the_car_address_and_internet_sharing():
net = FakeNet(tethering=True, car_address=False, nat=False)
carviewd.Hotspot(net).update()
assert net.asked("sudo", "addr", "add", f"{carviewd.CAR_ADDRESS}/32", "lo")
assert net.asked("iptables-legacy", "-A", "ppp0", "MASQUERADE")
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")
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")
def test_no_uplink_means_no_sharing_rule():
net = FakeNet(tethering=True, car_address=True, nat=False, uplink=None)
carviewd.Hotspot(net).update()
assert not net.asked("iptables-legacy")