carview: test page for the car's own browser, fed live from the comma

carviewd serves a three.js road scene on port 8099 and streams the car's
speed, gear and engagement to it as server-sent events. Every 2 s the page
posts back what it measured (frame rate, WebGL, how steadily data arrives,
pixel ratio), logged with the car's state to /data/media/0/carview/stats.jsonl,
so one drive shows whether the car's browser keeps drawing and receiving in
Drive.

- runs only while /data/media/0/carview/enabled exists, at nice 15; it only
  reads messages, and selfdrived ignores it if it dies
- cars.js: clay cars built in code (crossover, sedan, SUV, pickup, dark ego),
  lofted from side profiles with wheel arches, glass, mirrors and tail lights
  that can show braking; supersampled on top of MSAA, stepping down only on
  real slowness; /garage shows them up close
- ?flat draws a plain 2D canvas instead, to compare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Isaac Barham
2026-11-06 11:06:58 -05:00
parent bf906186b1
commit 9a3741250d
9 changed files with 1025 additions and 1 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ class SelfdriveD:
self.state_machine = StateMachine()
self.rk = Ratekeeper(100, print_delay_threshold=None)
self.ignored_processes = {'mapd', }
self.ignored_processes = {'mapd', 'carviewd'} # carviewd only serves a page, it never takes control away
# Determine startup event
is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
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.
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.
"""
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from collections.abc import Callable
from openpilot.common.hardware.hw import Paths
from openpilot.common.swaglog import cloudlog
PORT = 8099
SEND_HZ = 20
MAX_STREAMS = 4 # page reloads leave old streams open until their socket errors out
MAX_REPORT_BYTES = 16_384
STATS_MAX_BYTES = 5_000_000 # the log rolls over to stats.jsonl.1 past this
STATIC_DIR = Path(__file__).parent / "static"
# the only files served, by URL path
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"),
"/garage": ("garage.html", "text/html; charset=utf-8"),
"/cars.js": ("cars.js", "text/javascript; charset=utf-8"),
}
def carview_root() -> Path:
return Path(Paths.log_root()).parent / "carview"
def enabled_path() -> Path:
return carview_root() / "enabled"
def stats_path() -> Path:
return carview_root() / "stats.jsonl"
class CarSnapshot:
"""The latest car 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.lock = threading.Lock()
self.state: dict = {}
threading.Thread(target=self._run, daemon=True).start()
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'],
}
with self.lock:
self.state = state
time.sleep(1. / SEND_HZ)
def __call__(self) -> dict:
with self.lock:
return dict(self.state)
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
def append_report(path: Path, record: dict, max_bytes: int = STATS_MAX_BYTES) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and path.stat().st_size > max_bytes:
path.replace(path.with_name(path.name + ".1"))
with open(path, "a") as f:
f.write(json.dumps(record, separators=(",", ":")) + "\n")
def make_handler(snapshot: Callable[[], dict], log_path: Path, send_hz: float = SEND_HZ):
streams = threading.BoundedSemaphore(MAX_STREAMS)
seq_lock, seq = threading.Lock(), [0]
class Handler(BaseHTTPRequestHandler):
server_version = "carview"
def log_message(self, *args) -> None: # no stderr line per request
pass
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
path = self.path.split("?", 1)[0]
if path in STATIC_FILES:
name, content_type = STATIC_FILES[path]
self._send(200, (STATIC_DIR / name).read_bytes(), content_type)
elif path == "/events":
self._stream()
elif path == "/state.json":
self._send(200, json.dumps(snapshot()).encode(), "application/json")
else:
self._send(404, b"not found", "text/plain")
def _stream(self) -> None:
if not streams.acquire(blocking=False):
self._send(503, b"too many streams", "text/plain")
return
try:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
self.wfile.write(b"retry: 1000\n\n")
while True:
with seq_lock:
seq[0] += 1
n = seq[0]
msg = {'n': n, 'ts': wall_ms(), **snapshot()}
self.wfile.write(b"data: " + json.dumps(msg, separators=(",", ":")).encode() + b"\n\n")
self.wfile.flush()
time.sleep(1. / send_hz)
except OSError: # the page went away: broken pipe, reset or timeout
pass
finally:
streams.release()
def do_POST(self) -> None:
if self.path.split("?", 1)[0] != "/report":
self._send(404, b"not found", "text/plain")
return
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > MAX_REPORT_BYTES:
self._send(413, b"bad length", "text/plain")
return
try:
page = json.loads(self.rfile.read(length))
if not isinstance(page, dict):
raise ValueError("not an object")
except ValueError:
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})
self._send(200, json.dumps({'serverTime': server_ms}).encode(), "application/json")
return Handler
class CarviewServer(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address) -> None:
# a browser dropping an idle keep-alive connection is normal, anything else goes to the log instead of stderr
if not isinstance(sys.exc_info()[1], (ConnectionError, TimeoutError)):
cloudlog.exception(f"carviewd: request from {client_address[0]} failed")
def make_server(snapshot: Callable[[], dict], log_path: Path, host: str = "0.0.0.0", port: int = PORT,
send_hz: float = SEND_HZ) -> ThreadingHTTPServer:
return CarviewServer((host, port), make_handler(snapshot, log_path, send_hz))
def main() -> None:
os.nice(15) # well below the driving processes
server = make_server(CarSnapshot(), stats_path())
cloudlog.warning(f"carviewd serving on port {PORT}")
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,320 @@
// Clay car models for carview, built in code so the page needs no downloaded model files.
//
// Each car is one smooth lofted skin: a side profile gives the body's top and bottom lines (wheel arches included) and
// the cabin's roof line; at ~100 stations along the car a rounded cross-section is swept from them, full width below the
// beltline and leaning in toward the roof above it. Triangles on the cabin's sides, windscreen and rear window form the
// glass. Units are metres, the car's front points down -z and it sits on y = 0.
export const CAR_TYPES = ['crossover', 'sedan', 'suv', 'pickup'];
// Side profiles, front at +x. top: the body's outline, front bumper over the hood to the tail and back underneath
// (the wheel arches are added). cabin: the greenhouse outline, only its top edge is used. axles: wheel centres along x.
const PROFILES = {
crossover: {
width: 1.92, cabinRatio: 0.86, lean: 0.17, wheelR: 0.36, axles: [1.42, -1.47], sill: 0.25, archR: 0.43,
top: [['M', 2.24, 0.27], ['Q', 2.42, 0.31, 2.41, 0.50], ['Q', 2.39, 0.70, 2.20, 0.78], ['Q', 1.55, 0.97, 0.86, 1.02],
['L', -1.92, 1.07], ['Q', -2.30, 1.08, -2.37, 0.90], ['Q', -2.43, 0.52, -2.31, 0.31], ['Q', -2.25, 0.27, -2.10, 0.27]],
cabin: [['M', 0.98, 0.96], ['Q', 0.50, 1.31, 0.04, 1.54], ['Q', -0.64, 1.67, -1.30, 1.58], ['Q', -1.86, 1.47, -2.12, 1.10],
['L', -2.02, 0.98]],
},
sedan: {
width: 1.86, cabinRatio: 0.84, lean: 0.17, wheelR: 0.34, axles: [1.44, -1.44], sill: 0.21, archR: 0.41,
top: [['M', 2.28, 0.23], ['Q', 2.44, 0.28, 2.42, 0.47], ['Q', 2.39, 0.67, 2.18, 0.74], ['Q', 1.50, 0.88, 0.76, 0.92],
['L', -1.66, 0.98], ['Q', -2.12, 1.00, -2.34, 0.96], ['Q', -2.44, 0.62, -2.34, 0.29], ['Q', -2.28, 0.23, -2.12, 0.23]],
cabin: [['M', 0.88, 0.88], ['Q', 0.38, 1.21, -0.08, 1.41], ['Q', -0.62, 1.50, -1.12, 1.43], ['Q', -1.54, 1.31, -1.80, 1.00],
['L', -1.70, 0.92]],
},
suv: {
width: 1.98, cabinRatio: 0.90, lean: 0.12, wheelR: 0.39, axles: [1.50, -1.50], sill: 0.31, archR: 0.47,
top: [['M', 2.36, 0.33], ['Q', 2.51, 0.39, 2.49, 0.63], ['Q', 2.47, 0.91, 2.29, 0.99], ['Q', 1.70, 1.10, 1.02, 1.13],
['L', -2.28, 1.17], ['Q', -2.47, 1.17, -2.48, 0.99], ['Q', -2.51, 0.59, -2.40, 0.37], ['Q', -2.34, 0.33, -2.20, 0.33]],
cabin: [['M', 1.10, 1.08], ['Q', 0.78, 1.43, 0.40, 1.75], ['Q', -0.80, 1.83, -2.12, 1.79], ['Q', -2.38, 1.74, -2.44, 1.46],
['L', -2.40, 1.10]],
},
pickup: {
width: 2.02, cabinRatio: 0.88, lean: 0.12, wheelR: 0.40, axles: [1.92, -1.78], sill: 0.37, archR: 0.49,
top: [['M', 2.82, 0.41], ['Q', 2.97, 0.47, 2.95, 0.73], ['Q', 2.93, 1.03, 2.75, 1.11], ['Q', 2.10, 1.21, 1.30, 1.23],
['L', -2.80, 1.27], ['Q', -2.91, 1.27, -2.91, 1.10], ['Q', -2.93, 0.70, -2.84, 0.45], ['Q', -2.78, 0.41, -2.64, 0.41]],
cabin: [['M', 1.40, 1.16], ['Q', 1.08, 1.53, 0.70, 1.91], ['Q', -0.06, 1.97, -0.80, 1.93], ['Q', -0.93, 1.89, -0.95, 1.70],
['L', -0.97, 1.18]],
bed: [-1.05, -2.74],
},
};
const PAINTS = {
clay: { body: 0xd8dbdf, glass: 0x7f8792, roughness: 0.62, metalness: 0.0, glassRoughness: 0.22 },
ego: { body: 0x2f3338, glass: 0x0f1115, roughness: 0.3, metalness: 0.4, glassRoughness: 0.1 },
};
const STATIONS = 104;
const CORNER = 5; // points per rounded corner of a cross-section
function outline(THREE, cmds, p, withArches) {
const s = new THREE.Shape();
for (const [op, ...a] of cmds) {
if (op === 'M') s.moveTo(a[0], a[1]);
else if (op === 'L') s.lineTo(a[0], a[1]);
else if (op === 'Q') s.quadraticCurveTo(a[0], a[1], a[2], a[3]);
}
if (withArches) {
for (const ax of [...p.axles].sort((a, b) => a - b)) { // underside, rear to front, over each wheel
s.lineTo(ax - p.archR, p.sill);
s.absarc(ax, p.wheelR, p.archR, Math.PI + Math.asin(Math.min(1, (p.wheelR - p.sill) / p.archR)), -Math.asin(Math.min(1, (p.wheelR - p.sill) / p.archR)), true);
s.lineTo(ax + p.archR, p.sill);
}
}
s.closePath();
return s.getPoints(48);
}
// the outline split at its front- and rearmost points into an upper and a lower line, each sorted by x
function chains(points) {
let iMax = 0, iMin = 0;
points.forEach((q, i) => { if (q.x > points[iMax].x) iMax = i; if (q.x < points[iMin].x) iMin = i; });
const walk = (from, to) => { const out = []; for (let i = from; ; i = (i + 1) % points.length) { out.push(points[i]); if (i === to) break; } return out; };
const a = walk(iMax, iMin), b = walk(iMin, iMax);
const meanY = (c) => c.reduce((s, q) => s + q.y, 0) / c.length;
const sort = (c) => c.map(q => [q.x, q.y]).sort((u, v) => u[0] - v[0]);
return meanY(a) > meanY(b) ? { upper: sort(a), lower: sort(b), minX: points[iMin].x, maxX: points[iMax].x }
: { upper: sort(b), lower: sort(a), minX: points[iMin].x, maxX: points[iMax].x };
}
function yAt(chain, x, pick = Math.max) {
// linear between the points around x; where the line is vertical (an arch's side), pick decides
let lo = 0, hi = chain.length - 1;
if (x <= chain[0][0]) return chain[0][1];
if (x >= chain[hi][0]) return chain[hi][1];
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (chain[mid][0] <= x) lo = mid; else hi = mid; }
const [x0, y0] = chain[lo], [x1, y1] = chain[hi];
return x1 - x0 < 1e-6 ? pick(y0, y1) : y0 + (y1 - y0) * (x - x0) / (x1 - x0);
}
const arc = (cx, cy, r, a0, a1, out) => { for (let k = 0; k <= CORNER; k++) { const a = a0 + (a1 - a0) * k / CORNER; out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } };
// right half of a cross-section, bottom centre to roof centre, as [z, y]; glassFrom/glassTo index the cabin's side
function section(b, t, r, hw, cwBase, cwTop) {
const pts = [[0, b]];
const h = Math.max(t - b, 1e-4);
const rb = Math.min(0.10, h / 3, hw / 3), rs = Math.min(0.13, h / 3, hw / 3);
arc(hw - rb, b + rb, rb, -Math.PI / 2, 0, pts);
arc(hw - rs, t - rs, rs, 0, Math.PI / 2, pts);
const glassFrom = pts.length;
const cabinH = Math.max(r - t, 0);
const rr = Math.min(0.17, cabinH * 0.45, cwTop / 3);
pts.push([Math.min(cwBase, hw - rs), t]);
for (const f of [0.08, 0.5]) pts.push([cwBase + (cwTop - cwBase) * f, t + (cabinH - rr) * f]);
arc(cwTop - rr, r - rr, rr, 0, Math.PI / 2, pts);
const glassTo = pts.length - CORNER + 1; // the roof corner's lower part is still glass
pts.push([0, r]);
return { pts, glassFrom, glassTo };
}
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
function skin(THREE, p) {
const body = chains(outline(THREE, p.top, p, true));
const cabin = chains(outline(THREE, p.cabin, p, false));
const half = (body.maxX - body.minX) / 2, xc = (body.maxX + body.minX) / 2;
const planWidth = (x) => { const u = Math.min(1, Math.abs(x - xc) / (half * 1.015)); return Math.pow(1 - Math.pow(u, 5), 1 / 5); };
const roofAt = (x) => (x > cabin.minX && x < cabin.maxX) ? yAt(cabin.upper, x) : -1;
const positions = [], glass = [], paintTris = [], glassTris = [];
let ring = 0;
for (let i = 0; i < STATIONS; i++) {
// denser toward the ends, where the shape turns fastest
const s = i / (STATIONS - 1), x = xc - half * Math.cos(Math.PI * s) * 0.999;
const b = yAt(body.lower, x, Math.max), t = yAt(body.upper, x);
const r = Math.max(t, roofAt(x));
const hw = (p.width / 2) * planWidth(x);
const cwBase = hw * p.cabinRatio, cwTop = cwBase * (1 - p.lean);
const { pts, glassFrom, glassTo } = section(b, t, r, hw, cwBase, cwTop);
if (i === 0) ring = pts.length * 2 - 2;
const dr = (Math.max(t, roofAt(x + 0.05)) - Math.max(t, roofAt(x - 0.05))) / 0.1; // roof slope: windscreen or rear window
const cabinH = r - t;
const flags = [];
const loop = [...pts, ...pts.slice(1, -1).reverse().map(([z, y]) => [-z, y])];
const idx = (j) => (j < pts.length ? j : pts.length - 1 - (j - pts.length + 1)); // loop index to half index
loop.forEach(([z, y], j) => {
positions.push(x, y, z);
const k = idx(j);
const side = k >= glassFrom + 1 && k < glassTo;
const top = k >= glassTo && Math.abs(dr) > 0.42;
flags.push(cabinH > 0.12 && (side || top) && Math.abs(x - xc) < half - 0.12);
});
glass.push(flags);
}
// quads between stations, split into paint and glass triangles
for (let i = 0; i < STATIONS - 1; i++) {
for (let j = 0; j < ring; j++) {
const j1 = (j + 1) % ring;
const a = i * ring + j, bq = i * ring + j1, c = (i + 1) * ring + j, d = (i + 1) * ring + j1;
const isGlass = glass[i][j] && glass[i][j1] && glass[i + 1][j] && glass[i + 1][j1];
(isGlass ? glassTris : paintTris).push(a, c, bq, bq, c, d);
}
}
// close both ends with a fan around each end ring's centre, facing out: -x at the tail, +x at the nose
for (const [i, nose] of [[0, false], [STATIONS - 1, true]]) {
let cx = 0, cy = 0, cz = 0;
for (let j = 0; j < ring; j++) { cx += positions[(i * ring + j) * 3]; cy += positions[(i * ring + j) * 3 + 1]; cz += positions[(i * ring + j) * 3 + 2]; }
const centre = positions.length / 3;
positions.push(cx / ring, cy / ring, cz / ring);
for (let j = 0; j < ring; j++) {
const a = i * ring + j, bq = i * ring + (j + 1) % ring;
if (nose) paintTris.push(centre, bq, a); else paintTris.push(centre, a, bq);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
g.setIndex([...paintTris, ...glassTris]);
g.addGroup(0, paintTris.length, 0);
g.addGroup(paintTris.length, glassTris.length, 1);
g.computeVertexNormals();
return { g, beltlineAt: (x) => yAt(body.upper, x), halfWidthAt: (x) => (p.width / 2) * planWidth(x), cabinFront: cabin.maxX,
rearX: body.minX };
}
let shadowTexture = null;
function contactShadow(THREE, length, width) {
if (!shadowTexture) {
const c = document.createElement('canvas'); c.width = c.height = 128;
const ctx = c.getContext('2d');
const grd = ctx.createRadialGradient(64, 64, 6, 64, 64, 64);
grd.addColorStop(0, 'rgba(0,0,0,0.5)'); grd.addColorStop(0.5, 'rgba(0,0,0,0.26)'); grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd; ctx.fillRect(0, 0, 128, 128);
shadowTexture = new THREE.CanvasTexture(c);
}
const m = new THREE.Mesh(new THREE.PlaneGeometry(width * 1.5, length * 1.18),
new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, depthWrite: false }));
m.rotation.x = -Math.PI / 2; m.position.y = 0.012; m.renderOrder = -1;
return m;
}
const materialCache = new Map();
function materials(THREE, paintName) {
if (!materialCache.has(paintName)) {
const p = PAINTS[paintName] || PAINTS.clay;
materialCache.set(paintName, {
body: new THREE.MeshStandardMaterial({ color: p.body, roughness: p.roughness, metalness: p.metalness }),
glass: new THREE.MeshStandardMaterial({ color: p.glass, roughness: p.glassRoughness, metalness: 0.1 }),
tire: new THREE.MeshStandardMaterial({ color: 0x26282c, roughness: 0.85 }),
hub: new THREE.MeshStandardMaterial({ color: paintName === 'ego' ? 0x9097a0 : 0xcdd0d5, roughness: 0.45, metalness: 0.3 }),
bed: new THREE.MeshStandardMaterial({ color: 0x55595f, roughness: 0.9 }),
});
}
return materialCache.get(paintName);
}
const partsCache = new Map();
function parts(THREE, type) {
if (!partsCache.has(type)) {
const p = PROFILES[type];
const s = skin(THREE, p);
const tire = new THREE.CylinderGeometry(p.wheelR, p.wheelR, 0.25, 40, 1);
tire.rotateX(Math.PI / 2);
const hub = new THREE.CylinderGeometry(p.wheelR * 0.64, p.wheelR * 0.64, 0.02, 40, 1);
hub.rotateX(Math.PI / 2);
const mirror = new THREE.SphereGeometry(0.1, 20, 14);
mirror.scale(1.15, 0.85, 1.25);
partsCache.set(type, { ...s, tire, hub, mirror });
}
return partsCache.get(type);
}
// A car as a THREE.Group, front toward -z. paint: 'clay' for other traffic, 'ego' for our own car.
export function buildCar(THREE, type = 'crossover', paint = 'clay') {
type = PROFILES[type] ? type : 'crossover';
const p = PROFILES[type], g = parts(THREE, type), mat = materials(THREE, paint);
const car = new THREE.Group(); // what the caller moves around
const model = new THREE.Group(); // the profile's +x turned to -z
model.rotation.y = Math.PI / 2;
car.add(model);
const skinMesh = new THREE.Mesh(g.g, [mat.body, mat.glass]);
model.add(skinMesh);
if (p.bed) {
const [x0, x1] = p.bed, y = g.beltlineAt((x0 + x1) / 2);
const bed = new THREE.Mesh(new THREE.BoxGeometry(x0 - x1, 0.02, 2 * g.halfWidthAt((x0 + x1) / 2) - 0.3), mat.bed);
bed.position.set((x0 + x1) / 2, y + 0.006, 0);
model.add(bed);
}
// mirrors sit on the door tops just behind the windscreen's base, dug into the shoulder so they never float
const mx = g.cabinFront - 0.22, my = g.beltlineAt(mx) + 0.03, mz = g.halfWidthAt(mx) - 0.01;
for (const side of [-1, 1]) {
const m = new THREE.Mesh(g.mirror, mat.body);
m.position.set(mx, my, side * mz);
model.add(m);
}
const zWheel = p.width / 2 - 0.17;
for (const ax of p.axles) {
for (const side of [-1, 1]) {
const tire = new THREE.Mesh(g.tire, mat.tire);
tire.position.set(ax, p.wheelR, side * zWheel);
model.add(tire);
const hub = new THREE.Mesh(g.hub, mat.hub);
hub.position.set(ax, p.wheelR, side * (zWheel + 0.13));
model.add(hub);
}
}
// a tail light bar across the back, dark until car.userData.setBraking(true)
const lx = g.rearX + 0.07, ly = g.beltlineAt(lx + 0.15) - 0.12;
const lights = new THREE.MeshStandardMaterial({ color: 0x4a2124, roughness: 0.4, emissive: 0xff2b2b, emissiveIntensity: 0 });
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.07, 2 * g.halfWidthAt(lx) * 0.92), lights);
bar.position.set(lx, ly, 0);
model.add(bar);
const length = Math.max(...p.top.map(c => c[c.length - 2])) - Math.min(...p.top.map(c => c[c.length - 2]));
car.add(contactShadow(THREE, length, p.width));
car.userData = {
type, length, width: p.width,
setBraking: (on) => { lights.emissiveIntensity = on ? 2.4 : 0; lights.color.setHex(on ? 0xc92a2a : 0x4a2124); },
};
return car;
}
// Supersampling on top of the renderer's own multisampling: draw at 1.5-3x the screen's pixels and let the browser
// scale it down, so edges stay smooth even on a 1x screen. Steps down a quarter at a time while the frame rate is under
// minFps for two seconds running, never below 1x. Call tick(now) once per frame; it returns the pixel ratio in use.
export function supersampler(renderer, { minFps = 45, start = null } = {}) {
const dpr = window.devicePixelRatio || 1;
let ratio = start ?? Math.min(3, Math.max(1.5, dpr * 1.5));
renderer.setPixelRatio(ratio);
let frames = 0, since = performance.now(), slowWindows = 0;
return {
get ratio() { return ratio; },
tick(now) {
frames++;
if (now - since < 1000) return ratio;
const fps = frames * 1000 / (now - since);
frames = 0; since = now;
// under ~8 fps the browser is throttling a hidden page, not struggling to draw: leave the resolution alone
slowWindows = fps >= 8 && fps < minFps ? slowWindows + 1 : 0;
if (slowWindows >= 2 && ratio > 1) {
ratio = Math.max(1, ratio - 0.25); slowWindows = 0;
renderer.setPixelRatio(ratio);
renderer.setSize(renderer.domElement.clientWidth, renderer.domElement.clientHeight, false);
}
return ratio;
},
};
}
// Soft studio light for the clay look: a pre-filtered environment from a few glowing panels around a grey room.
export function studioEnvironment(THREE, renderer) {
const room = new THREE.Scene();
room.background = new THREE.Color(0x8e9197);
const panel = (w, h, x, y, z, intensity) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(intensity, intensity, intensity), side: THREE.DoubleSide }));
m.position.set(x, y, z); m.lookAt(0, 0, 0); room.add(m);
};
panel(16, 16, 0, 12, 0, 3.0); // big overhead softbox
panel(12, 5, 0, 4, -13, 1.6); // front strip
panel(12, 5, 13, 3, 0, 1.1); // side fills
panel(12, 5, -13, 3, 0, 0.9);
panel(12, 5, 0, 3, 13, 1.2); // behind
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshBasicMaterial({ color: 0x5e6166 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -2; room.add(floor);
const pmrem = new THREE.PMREMGenerator(renderer);
const env = pmrem.fromScene(room, 0.035).texture;
pmrem.dispose();
return env;
}
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>carview garage</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #dcdde0;
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
canvas { display: block; width: 100vw; height: 100vh; }
#bar { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; gap: 8px; }
#bar button { font-size: 15px; padding: 10px 16px; border-radius: 999px; border: 0; background: rgba(255,255,255,0.9);
color: #1d1f22; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,0.1); }
#bar button.on { background: #1d1f22; color: #fff; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="bar"></div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
import { buildCar, studioEnvironment, supersampler, CAR_TYPES } from '/cars.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
const ss = supersampler(renderer);
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdcdde0);
scene.environment = studioEnvironment(THREE, renderer);
scene.environmentIntensity = 0.75;
const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(-5, 9, 6); scene.add(sun);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ color: 0xeeeff1, roughness: 1 }));
floor.rotation.x = -Math.PI / 2; scene.add(floor);
const cars = [];
const params = new URLSearchParams(location.search);
// ?car=sedan shows one car up close, otherwise the whole row
const row = params.get('car') ? [[params.get('car'), params.get('paint') || 'clay']] : [...CAR_TYPES.map(t => [t, 'clay']), ['crossover', 'ego']];
row.forEach(([type, paint], i) => {
const car = buildCar(THREE, type, paint);
car.position.x = (i - (row.length - 1) / 2) * 6.4;
scene.add(car); cars.push(car);
});
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 200);
const VIEWS = { front: [0.55, 0.30], side: [Math.PI / 2, 0.12], rear: [Math.PI + 0.6, 0.32], chase: [Math.PI, 0.45] };
let view = new URLSearchParams(location.search).get('view') || 'front';
const bar = document.getElementById('bar');
for (const v of Object.keys(VIEWS)) {
const b = document.createElement('button'); b.textContent = v;
b.onclick = () => { view = v; for (const x of bar.children) x.className = x === b ? 'on' : ''; };
if (v === view) b.className = 'on';
bar.appendChild(b);
}
const resize = () => {
renderer.setSize(window.innerWidth, window.innerHeight, false);
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
};
window.addEventListener('resize', resize); resize();
const spin = new URLSearchParams(location.search).has('spin');
function frame(t) {
const [yaw, pitch] = VIEWS[view];
// the camera stays put in front of the row and every car turns to the chosen angle, so they're all seen alike
cars.forEach(c => { c.rotation.y = Math.PI + yaw + (spin ? t / 4000 : 0); });
const d = row.length > 1 ? 40 : 12;
camera.position.set(0, d * Math.sin(pitch) + 0.6, d * Math.cos(pitch));
camera.lookAt(0, 0.7, 0);
ss.tick(t);
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
@@ -0,0 +1,289 @@
<!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 fullscreen</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, 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 = () => {
// 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,134 @@
"""
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.
"""
import http.client
import json
import threading
import pytest
from openpilot.sunnypilot.system.carview import carviewd
CAR = {'vEgo': 12.5, 'gear': 'drive', 'engaged': True, 'mads': True, 'vCruise': 104.6}
@pytest.fixture
def server(tmp_path):
srv = carviewd.make_server(lambda: dict(CAR), tmp_path / "stats.jsonl", host="127.0.0.1", port=0, send_hz=200)
threading.Thread(target=srv.serve_forever, daemon=True).start()
yield srv, tmp_path / "stats.jsonl"
srv.shutdown()
srv.server_close()
def request(srv, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", srv.server_address[1], timeout=5)
conn.request(method, path, body=body, headers=headers or {})
return conn, conn.getresponse()
def test_serves_the_page(server):
srv, _ = server
_, r = request(srv, "GET", "/")
assert r.status == 200
assert r.getheader("Content-Type").startswith("text/html")
assert b"new EventSource('/events')" in r.read()
@pytest.mark.parametrize("path,content_type,marker", [
("/cars.js", "text/javascript", b"export function buildCar"),
("/garage", "text/html", b"import { buildCar"),
])
def test_serves_the_car_models_and_the_garage(server, path, content_type, marker):
srv, _ = server
_, r = request(srv, "GET", path)
assert r.status == 200
assert r.getheader("Content-Type").startswith(content_type)
assert marker in r.read()
def test_every_served_file_exists():
for name, _ in carviewd.STATIC_FILES.values():
assert (carviewd.STATIC_DIR / name).is_file()
def test_unknown_paths_are_not_found(server):
srv, _ = server
for method, path in (("GET", "/../carviewd.py"), ("GET", "/static/index.html"), ("POST", "/")):
_, r = request(srv, method, path, body=b"{}", headers={"Content-Length": "2"})
assert r.status == 404
def test_streams_the_car_state_with_a_sequence(server):
srv, _ = server
conn, r = request(srv, "GET", "/events")
assert r.getheader("Content-Type") == "text/event-stream"
events = []
while len(events) < 3:
line = r.fp.readline().decode().strip()
if line.startswith("data: "):
events.append(json.loads(line[len("data: "):]))
conn.close()
assert all(e['vEgo'] == CAR['vEgo'] and e['gear'] == 'drive' and 'ts' in e for e in events)
assert [e['n'] for e in events] == sorted(e['n'] for e in events) and len({e['n'] for e in events}) == 3
def test_a_report_is_logged_with_the_car_state(server):
srv, log = server
page = {'fps': 60, 'webgl2': True, 'visibility': 'visible'}
_, r = request(srv, "POST", "/report", body=json.dumps(page), headers={"Content-Type": "application/json"})
assert r.status == 200 and 'serverTime' in json.loads(r.read())
record = json.loads(log.read_text().splitlines()[-1])
assert record['page'] == page
assert record['car']['gear'] == 'drive' and record['car']['vEgo'] == CAR['vEgo']
assert record['client'] == "127.0.0.1"
@pytest.mark.parametrize("body", [b"not json", b"[1, 2]", b""])
def test_a_bad_report_is_refused_and_not_logged(server, body):
srv, log = server
_, r = request(srv, "POST", "/report", body=body, headers={"Content-Length": str(len(body))})
assert r.status in (400, 413)
assert not log.exists()
def test_an_oversized_report_is_refused(server):
srv, log = server
body = json.dumps({'pad': "x" * carviewd.MAX_REPORT_BYTES})
_, r = request(srv, "POST", "/report", body=body)
assert r.status == 413
assert not log.exists()
def test_too_many_streams_are_turned_away(server):
srv, _ = server
open_streams = [request(srv, "GET", "/events") for _ in range(carviewd.MAX_STREAMS)]
assert all(r.status == 200 for _, r in open_streams)
_, r = request(srv, "GET", "/events")
assert r.status == 503
for conn, _ in open_streams:
conn.close()
def test_the_log_rolls_over_past_its_size(tmp_path):
log = tmp_path / "stats.jsonl"
for i in range(5):
carviewd.append_report(log, {'i': i, 'pad': "x" * 40}, max_bytes=100)
rolled = tmp_path / "stats.jsonl.1"
assert rolled.exists()
assert log.stat().st_size <= 100 + 60
last = [json.loads(line)['i'] for line in log.read_text().splitlines()]
assert last[-1] == 4
def test_it_runs_only_while_the_enabled_file_exists(tmp_path, monkeypatch):
from openpilot.system.manager import process_config
flag = tmp_path / "enabled"
monkeypatch.setattr(process_config, "carview_enabled_path", lambda: flag)
assert not process_config.carview(True, None, None)
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)
@@ -10,6 +10,7 @@ from openpilot.system.manager.process import PythonProcess, NativeProcess, Daemo
from openpilot.common.hardware.hw import Paths
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH
from openpilot.sunnypilot.system.carview.carviewd import enabled_path as carview_enabled_path
from openpilot.sunnypilot.models.helpers import get_active_model_runner
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, use_sunnylink_uploader
@@ -71,6 +72,9 @@ def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool:
def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
return params.get_bool("IsLiveStreaming")
def carview(started: bool, params: Params, CP: car.CarParams) -> bool:
return os.path.exists(carview_enabled_path())
def use_copyparty(started, params, CP: car.CarParams) -> bool:
return bool(params.get_bool("EnableCopyparty"))
@@ -186,6 +190,9 @@ procs += [
# locationd
NativeProcess("locationd_llk", "openpilot/sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad),
# a page for the car's own browser, only while carview's enabled file exists
PythonProcess("carviewd", "openpilot.sunnypilot.system.carview.carviewd", carview),
]
if os.path.exists("../../sunnypilot/sunnylink/uploader.py"):