Files
Prabhaav Pillai c532223fa0 Restore V-Adj Spot Monitoring warnings and polish Galaxy settings, cameras, and updates
Bundle the verified VASM model, enable it at startup, and split VASM and vision speed-sign inference across little cores when both are active.
Restore VASM warnings in PiP activation, red outlines, and onroad blind-spot borders, including expanded Android Auto views.
Improve cabin snapshots with longer warm-up, adjusted capture timeouts, a fresh preview frame, and clearer capture buttons and save prompts.
Polish Galaxy settings with dimmed gated controls and icons, clearer vehicle fingerprinting and steering availability descriptions, rounded speed-offset ranges, and immediate refresh after saving.
Improve updater startup messages and preserve update/reconnect screens across service interruptions and restarts.
Add regression tests for the updated behavior.
2026-10-19 23:07:19 -04:00

330 lines
14 KiB
Python

"""Synthetic serialized cabin planes and fail-closed observation lifecycle."""
from pathlib import Path
from unittest.mock import patch
from openpilot.common.params import Params
from openpilot.starpilot.spot_monitor.inference import VASMInference
from openpilot.starpilot.spot_monitor.policy import decode_annotation
from openpilot.starpilot.spot_monitor.preferences import Preferences, encode
from openpilot.starpilot.spot_monitor.runtime import CpuBudget, VASMRuntime, cabin_client_factory, copy_cabin_nv12, publish_observation
class State:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class Messages:
def __init__(self, clock):
from opendbc.car.structs import car
self.clock = clock
self.seen = dict.fromkeys(("deviceState", "carState"), True)
self.alive = self.seen.copy()
self.valid = self.seen.copy()
self.logMonoTime = dict.fromkeys(self.seen, clock[0])
self.recv_time = dict.fromkeys(self.seen, clock[0] / 1e9)
self.values = {"deviceState": State(started=True, cpuUsagePercent=[10.0] * 8),
"carState": State(canValid=True, gearShifter=car.CarState.GearShifter.drive)}
def __getitem__(self, name):
return self.values[name]
def update(self, timeout):
assert timeout == 0
def refresh(self):
for name in self.seen:
self.logMonoTime[name] = self.clock[0]
self.recv_time[name] = self.clock[0] / 1e9
class Client:
width, height, stride, uv_offset = 32, 32, 40, 1320
def __init__(self):
self.frames = []
self.connected = True
def is_connected(self):
return self.connected
def connect(self, blocking):
assert blocking is False
self.connected = True
def recv(self, timeout):
assert timeout == 0
if not self.frames:
return None
self.timestamp_eof, self.frame_id, raw = self.frames.pop(0)
return State(data=raw)
def add(self, eof, frame_id):
raw = bytearray(self.uv_offset + self.stride * self.height // 2)
for y in range(self.height):
raw[y * self.stride:y * self.stride + self.width] = bytes([y]) * self.width
raw[self.uv_offset:] = bytes([128]) * (len(raw) - self.uv_offset)
self.frames.append((eof, frame_id, bytes(raw)))
class Model(VASMInference):
def __init__(self):
super().__init__(Path("/external/model.onnx"))
self.loaded_sha256 = "5d20cdbb457ba18db51a537ee2e305bbe442264b1613956068d473e35d15900d"
def load(self):
return True
def infer_nv12(self, frame, **kwargs):
assert frame.shape == (48, 32) and frame[31, 0] == 31 and frame[32, 0] == 128
return 0.96
def test_stride_uv_offset_copy_and_short_buffer_refused():
client = Client()
client.add(2_000_000_000, 0)
copied = copy_cabin_nv12(client)
assert copied is not None
image, eof, frame_id = copied
assert image.shape == (48, 32) and eof == 2_000_000_000 and frame_id == 0
assert image[31, 0] == 31 and image[32, 0] == 128
client.frames.append((2_000_000_100, 1, b"short"))
assert copy_cabin_nv12(client) is None
def test_real_vision_stream_enum_selects_only_existing_camerad_cabin():
from openpilot.cereal.visionipc import VisionStreamType
calls = []
def constructor(*args):
calls.append(args)
return object()
cabin_client_factory(constructor, VisionStreamType)
assert calls == [("camerad", VisionStreamType.VISION_STREAM_CABIN, True)]
def test_cpu_budget_frozen_target_smoothing_and_invalid_conservative():
budget = CpuBudget()
assert budget.update([10.0] * 8, now_mono_ns=1_000_000_000) == 1.0
busy = budget.update([90.0] * 8, now_mono_ns=6_000_000_000)
assert 3.3 < busy < 3.6 # frozen hot-core target 3.5, smoothed
recovered = budget.update([0.0] * 8, now_mono_ns=11_000_000_000)
assert 1.0 < recovered < 1.1
assert budget.update([], now_mono_ns=12_000_000_000) == 4.0
assert budget.update([float("nan")], now_mono_ns=13_000_000_000) == 4.0
assert budget.update([True], now_mono_ns=14_000_000_000) == 4.0
def test_vasm_uses_its_own_core_with_vision_and_restores_solo_cores(tmp_path):
params = Params(str(tmp_path / "params"))
clock = [1_000_000_000]
runtime = VASMRuntime(params, Messages(clock), Client, lambda _event: None)
with patch('openpilot.starpilot.spot_monitor.runtime.feature_requested', side_effect=[True, False]), \
patch('openpilot.starpilot.spot_monitor.runtime.set_core_affinity') as affinity:
runtime._refresh_settings(clock[0])
assert runtime.inference_cores == (2,)
clock[0] += 2_000_000_000
runtime._refresh_settings(clock[0])
assert runtime.inference_cores == (0, 1, 2)
assert [call.args[0] for call in affinity.call_args_list] == [[2], [0, 1, 2]]
def test_enabled_fresh_drive_new_frames_and_stale_reset(tmp_path):
from opendbc.car.structs import car
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
messages = Messages(clock)
client = Client()
client.add(2_000_000_000, 0)
events = []
runtime = VASMRuntime(params, messages, lambda: client, events.append, model_path=Path("/external/model.onnx"),
mono_clock=lambda: clock[0], pair_clock=lambda: (clock[0], clock[0] + 1_000_000_000),
model_factory=lambda _: Model())
event = runtime.tick()
assert event is not None and event.status == "valid" and event.frame_id == 0
assert event.display_right and not event.display_left
assert event.left_frame_id == 0 and event.left_eof_boot_ns == 2_000_000_000
assert event.observed_mono_ns == clock[0] and event.observed_boot_ns == clock[0] + 1_000_000_000
assert event.sequence == 1
assert len(event.settings_fingerprint) == 64 and len(event.session) == 32
sent = []
publish_observation(State(send=lambda service, message: sent.append((service, message))), event)
service, envelope = sent[0]
assert service == "spotMonitorState" and envelope.valid and envelope.logMonoTime == event.observed_mono_ns
assert envelope.spotMonitorState.desiredCurvature == 0
wire = envelope.spotMonitorState.observation
assert wire.version == 1 and wire.sequence == 1 and wire.sourceFrameId == 0
assert wire.right.status == "warning" and wire.right.sourceFrameEofBootTime == event.left_eof_boot_ns
assert wire.left.status == "unknown" and wire.left.sourceFrameEofBootTime == 0
clock[0] += 1_100_000_000
messages.refresh()
client.add(clock[0] + 1_000_000_000, 0)
assert runtime.tick() is None # duplicate frame cannot renew held warning
assert events[-1].status == "stale" and not events[-1].display_right
clock[0] += 600_000_000
messages.refresh()
messages.values["carState"].gearShifter = car.CarState.GearShifter.park
assert runtime.tick() is None
assert events[-1].status == "unavailable"
assert not events[-1].display_right
def test_alternating_sides_do_not_renew_other_source_and_resume_resets(tmp_path):
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[[0,0],[32,0],[32,32],[0,32]]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
offset = [1_000_000_000]
messages = Messages(clock)
client = Client()
client.add(2_000_000_000, 0)
events = []
runtime = VASMRuntime(params, messages, lambda: client, events.append, model_path=Path("/external/model.onnx"),
mono_clock=lambda: clock[0], pair_clock=lambda: (clock[0], clock[0] + offset[0]),
model_factory=lambda _: Model())
first = runtime.tick()
assert first is not None and first.display_right and not first.display_left
clock[0] += 350_000_000
messages.refresh()
client.add(clock[0] + offset[0], 1)
second = runtime.tick()
assert second is not None and second.display_right and second.display_left
assert second.left_eof_boot_ns == first.left_eof_boot_ns
assert second.right_eof_boot_ns > second.left_eof_boot_ns
sent = []
publish_observation(State(send=lambda service, message: sent.append(message)), second)
wire = sent[0].spotMonitorState.observation
assert wire.right.sourceFrameId == 0 and wire.left.sourceFrameId == 1
assert wire.right.validUntilBootTime == first.left_eof_boot_ns + 3_000_000_000
clock[0] += 1_100_000_000
messages.refresh()
offset[0] += 9_000_000_000 # suspend/resume clock-pair discontinuity
client.add(clock[0] + offset[0], 2)
assert runtime.tick() is None
assert events[-1].status == "stale" and not events[-1].display_left and not events[-1].display_right
def test_authority_or_saved_doc_change_during_inference_denies_output(tmp_path):
from opendbc.car.structs import car
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
messages = Messages(clock)
client = Client()
client.add(2_000_000_000, 0)
events = []
class ChangingModel(Model):
def infer_nv12(self, frame, **kwargs):
messages.values["carState"].gearShifter = car.CarState.GearShifter.park
return super().infer_nv12(frame, **kwargs)
runtime = VASMRuntime(params, messages, lambda: client, events.append, model_path=Path("/external/model.onnx"),
mono_clock=lambda: clock[0], pair_clock=lambda: (clock[0], clock[0] + 1_000_000_000),
model_factory=lambda _: ChangingModel())
assert runtime.tick() is None
assert all(event.status != "valid" for event in events)
messages.values["carState"].gearShifter = car.CarState.GearShifter.drive
client.add(2_000_000_100, 1)
class SavingModel(Model):
def infer_nv12(self, frame, **kwargs):
path.write_bytes(encode(Preferences(False, annotation)))
return super().infer_nv12(frame, **kwargs)
runtime.model = SavingModel()
assert runtime.tick() is None
assert all(event.status != "valid" for event in events)
def test_reconnect_rejects_queued_preconnection_frame(tmp_path):
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
messages = Messages(clock)
client = Client()
client.add(2_000_000_000, 0)
events = []
runtime = VASMRuntime(params, messages, lambda: client, events.append, model_path=Path("/external/model.onnx"),
mono_clock=lambda: clock[0], pair_clock=lambda: (clock[0], clock[0] + 1_000_000_000),
model_factory=lambda _: Model())
assert runtime.tick() is not None
client.connected = False
clock[0] += 100_000_000
messages.refresh()
client.add(2_050_000_000, 1) # queued from before the new connection
assert runtime.tick() is None
clock[0] += 100_000_000
messages.refresh()
assert runtime.tick() is None and events[-1].status == "stale"
assert all(event.sequence <= 1 for event in events)
def test_model_load_failure_retries_only_after_bounded_backoff(tmp_path):
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
messages = Messages(clock)
attempts = []
class BadModel(Model):
def load(self):
attempts.append(clock[0])
return False
runtime = VASMRuntime(params, messages, Client, lambda event: None,
model_path=Path("/external/model.onnx"), mono_clock=lambda: clock[0],
pair_clock=lambda: (clock[0], clock[0] + 1_000_000_000), model_factory=lambda _: BadModel())
assert runtime.tick() is None
clock[0] += 4_000_000_000
messages.refresh()
assert runtime.tick() is None
assert attempts == [1_000_000_000]
clock[0] += 1_000_000_000
messages.refresh()
assert runtime.tick() is None
assert attempts == [1_000_000_000, 6_000_000_000]
def test_source_timestamp_advancing_during_post_inference_update_is_checked_with_final_clock(tmp_path):
annotation = decode_annotation(b'{"version":1,"width":32,"height":32,"poly_left":[[0,0],[32,0],[32,32],[0,32]],"poly_right":[]}')
params = Params(str(tmp_path / "params"))
path = Path(params.get_param_path("VASMPreferences"))
path.write_bytes(encode(Preferences(True, annotation)))
clock = [1_000_000_000]
class UpdatingMessages(Messages):
calls = 0
def update(self, timeout):
super().update(timeout)
self.calls += 1
if self.calls == 3: # final update after inference receives a newer state
clock[0] += 100_000_000
self.refresh()
messages = UpdatingMessages(clock)
client = Client()
client.add(2_000_000_000, 0)
runtime = VASMRuntime(params, messages, lambda: client, lambda event: None,
model_path=Path("/external/model.onnx"), mono_clock=lambda: clock[0],
pair_clock=lambda: (clock[0], clock[0] + 1_000_000_000), model_factory=lambda _: Model())
event = runtime.tick()
assert event is not None and event.status == "valid"
assert event.observed_mono_ns == 1_100_000_000 and event.observed_boot_ns == 2_100_000_000