diff --git a/openpilot/sunnypilot/system/carview/carviewd.py b/openpilot/sunnypilot/system/carview/carviewd.py index 2dfbdb11cc..4c4efe3c4b 100644 --- a/openpilot/sunnypilot/system/carview/carviewd.py +++ b/openpilot/sunnypilot/system/carview/carviewd.py @@ -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() diff --git a/openpilot/sunnypilot/system/carview/static/index.html b/openpilot/sunnypilot/system/carview/static/index.html index 55b6bb4aa2..d3732256ff 100644 --- a/openpilot/sunnypilot/system/carview/static/index.html +++ b/openpilot/sunnypilot/system/carview/static/index.html @@ -3,43 +3,65 @@ -carview test +carview @@ -47,243 +69,201 @@
P R N D
-
--
+
+ +
--
+ +
MPH
-
--MAX
off
-
-
- -
-

carview test

-
checking WebGL...
-
…Live data from the comma
-
…Drawing
-
…WebGL 2
-
…three.js
-
…Reports to the comma
- -

Leave this open and drive. Every 2 s the page tells the comma what it measured.

+
+ + + +
--MAX
+
SPEED
LIMIT
--
+
- diff --git a/openpilot/sunnypilot/system/carview/static/test.html b/openpilot/sunnypilot/system/carview/static/test.html new file mode 100644 index 0000000000..f26e273151 --- /dev/null +++ b/openpilot/sunnypilot/system/carview/static/test.html @@ -0,0 +1,290 @@ + + + + + +carview test + + + + + +
+
P R N D
+
--
+
MPH
+
--MAX
off
+
+
+ +
+

carview test

+
checking WebGL...
+
…Live data from the comma
+
…Drawing
+
…WebGL 2
+
…three.js
+
…Reports to the comma
+ +

Leave this open and drive. Every 2 s the page tells the comma what it measured.

+
+ + + + diff --git a/openpilot/sunnypilot/system/carview/static/view.js b/openpilot/sunnypilot/system/carview/static/view.js new file mode 100644 index 0000000000..fee922e8b9 --- /dev/null +++ b/openpilot/sunnypilot/system/carview/static/view.js @@ -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; } }; +} diff --git a/openpilot/sunnypilot/system/carview/tests/test_carviewd.py b/openpilot/sunnypilot/system/carview/tests/test_carviewd.py index 3603ba65a7..50242e5d8e 100644 --- a/openpilot/sunnypilot/system/carview/tests/test_carviewd.py +++ b/openpilot/sunnypilot/system/carview/tests/test_carviewd.py @@ -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")