mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Controls Loop Optimizations
This commit is contained in:
@@ -495,9 +495,11 @@ class Controls:
|
||||
def main():
|
||||
config_realtime_process(4, Priority.CTRL_HIGH)
|
||||
controls = Controls()
|
||||
set_core_affinity([4, 5])
|
||||
if HARDWARE.get_device_type() in ("tici", "tizi", "mici"):
|
||||
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(Priority.CTRL_HIGH - 1))
|
||||
set_core_affinity([6])
|
||||
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
|
||||
else:
|
||||
set_core_affinity([4, 5])
|
||||
controls.run()
|
||||
|
||||
|
||||
|
||||
@@ -18,24 +18,28 @@ def test_controls_main_priority_is_applied_after_initialization_and_affinity(dev
|
||||
mock.patch.object(controlsd, "Controls", side_effect=lambda: calls.append(("construct",)) or controls),
|
||||
mock.patch.object(controlsd, "set_core_affinity", side_effect=lambda cores: calls.append(("affinity", cores))),
|
||||
mock.patch.object(controlsd.HARDWARE, "get_device_type", return_value=device),
|
||||
mock.patch.object(controlsd.os, "SCHED_FIFO", getattr(controlsd.os, "SCHED_FIFO", 1), create=True),
|
||||
mock.patch.object(controlsd.os, "SCHED_OTHER", getattr(controlsd.os, "SCHED_OTHER", 0), create=True),
|
||||
mock.patch.object(controlsd.os, "sched_param", getattr(controlsd.os, "sched_param",
|
||||
lambda priority: SimpleNamespace(sched_priority=priority)), create=True),
|
||||
mock.patch.object(controlsd.os, "sched_setscheduler", create=True, side_effect=priority_changed)):
|
||||
controlsd.main()
|
||||
fifo = controlsd.os.SCHED_FIFO
|
||||
expected = [("configure", 4, controlsd.Priority.CTRL_HIGH), ("construct",), ("affinity", [4, 5])]
|
||||
other = controlsd.os.SCHED_OTHER
|
||||
expected = [("configure", 4, controlsd.Priority.CTRL_HIGH), ("construct",)]
|
||||
if device in ("tici", "tizi", "mici"):
|
||||
expected.append(("priority", 0, fifo, 52))
|
||||
expected.extend([("affinity", [6]), ("priority", 0, other, 0)])
|
||||
else:
|
||||
expected.append(("affinity", [4, 5]))
|
||||
assert calls == [*expected, ("run",)]
|
||||
|
||||
|
||||
def test_constructor_failure_does_not_reprioritize_existing_threads():
|
||||
with (mock.patch.object(controlsd, "config_realtime_process"),
|
||||
mock.patch.object(controlsd, "Controls", side_effect=RuntimeError("initialization failed")),
|
||||
mock.patch.object(controlsd, "set_core_affinity") as affinity,
|
||||
mock.patch.object(controlsd.os, "sched_setscheduler", create=True) as priority,
|
||||
pytest.raises(RuntimeError, match="initialization failed")):
|
||||
controlsd.main()
|
||||
affinity.assert_not_called()
|
||||
priority.assert_not_called()
|
||||
|
||||
|
||||
@@ -43,12 +47,28 @@ def test_scheduler_failure_does_not_start_controls():
|
||||
controls = mock.Mock()
|
||||
with (mock.patch.object(controlsd, "config_realtime_process"),
|
||||
mock.patch.object(controlsd, "Controls", return_value=controls),
|
||||
mock.patch.object(controlsd, "set_core_affinity"),
|
||||
mock.patch.object(controlsd, "set_core_affinity") as affinity,
|
||||
mock.patch.object(controlsd.HARDWARE, "get_device_type", return_value="mici"),
|
||||
mock.patch.object(controlsd.os, "SCHED_FIFO", getattr(controlsd.os, "SCHED_FIFO", 1), create=True),
|
||||
mock.patch.object(controlsd.os, "SCHED_OTHER", getattr(controlsd.os, "SCHED_OTHER", 0), create=True),
|
||||
mock.patch.object(controlsd.os, "sched_param", getattr(controlsd.os, "sched_param",
|
||||
lambda priority: SimpleNamespace(sched_priority=priority)), create=True),
|
||||
mock.patch.object(controlsd.os, "sched_setscheduler", create=True, side_effect=PermissionError("priority denied")),
|
||||
pytest.raises(PermissionError, match="priority denied")):
|
||||
controlsd.main()
|
||||
affinity.assert_called_once_with([6])
|
||||
controls.run.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ("mici", "tizi", "tici", "pc", "unknown"))
|
||||
def test_affinity_failure_does_not_reprioritize_or_start_controls(device):
|
||||
controls = mock.Mock()
|
||||
with (mock.patch.object(controlsd, "config_realtime_process"),
|
||||
mock.patch.object(controlsd, "Controls", return_value=controls),
|
||||
mock.patch.object(controlsd.HARDWARE, "get_device_type", return_value=device),
|
||||
mock.patch.object(controlsd, "set_core_affinity", side_effect=PermissionError("affinity denied")) as affinity,
|
||||
mock.patch.object(controlsd.os, "sched_setscheduler", create=True) as priority,
|
||||
pytest.raises(PermissionError, match="affinity denied")):
|
||||
controlsd.main()
|
||||
affinity.assert_called_once_with([6] if device in ("tici", "tizi", "mici") else [4, 5])
|
||||
priority.assert_not_called()
|
||||
controls.run.assert_not_called()
|
||||
|
||||
@@ -1029,7 +1029,7 @@ class SelfdriveD:
|
||||
drive = int(self.sm['deviceState'].startedMonoTime) if self.sm['deviceState'].started else 0
|
||||
switchback = bool(self.switchback_capable and self.sm.seen['slcState'] and self.sm.alive['slcState'] and
|
||||
self.sm.valid['slcState'] and self.sm['carControl'].latActive and
|
||||
self.switchback_status.sample(self.sm['slcState'], drive_id=drive, now_ns=now_ns,
|
||||
self.switchback_status.sample_state(self.sm['slcState'], drive_id=drive, now_ns=now_ns,
|
||||
event_ns=int(self.sm.logMonoTime['slcState'])))
|
||||
if now_ns - self.switchback_setting_ns >= 1_000_000_000:
|
||||
self.switchback_setting_ns = now_ns
|
||||
|
||||
@@ -166,12 +166,25 @@ def switchback_observation(event, *, drive_id: int, now_ns: int, event_ns: int |
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SwitchbackStateEnvelope:
|
||||
# Explicit payload entry avoids probing nonexistent fields on a Capnp reader.
|
||||
slcState: object
|
||||
logMonoTime: int
|
||||
valid: bool
|
||||
|
||||
|
||||
class SwitchbackStatusOwner:
|
||||
def __init__(self):
|
||||
self.drive = 0
|
||||
self.last = None
|
||||
self.retired = ()
|
||||
|
||||
def sample_state(self, state, *, drive_id: int, now_ns: int, event_ns: int, valid: bool = True) -> bool:
|
||||
"""Explicit SlcState payload + original envelope metadata; no retained cache."""
|
||||
return self.sample(_SwitchbackStateEnvelope(state, event_ns, valid),
|
||||
drive_id=drive_id, now_ns=now_ns, event_ns=event_ns)
|
||||
|
||||
def sample(self, event, *, drive_id: int, now_ns: int, event_ns: int | None = None) -> bool:
|
||||
if drive_id != self.drive or drive_id <= 0:
|
||||
self.drive, self.last, self.retired = drive_id, None, ()
|
||||
|
||||
@@ -177,6 +177,49 @@ def test_leased_switchback_status_rejects_expiry_reorder_and_retired_session():
|
||||
assert not status.sample(first, drive_id=DRIVE, now_ns=NOW + 1)
|
||||
|
||||
|
||||
def test_switchback_payload_reader_matches_generic_envelope_metadata():
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.controllers.mode_actions import ModeIntent, publish_switchback, SwitchbackStatusOwner
|
||||
|
||||
# Actual SubMaster payload shape; compare to the existing public envelope API.
|
||||
for valid, age, stamp_delta, drive, expected in (
|
||||
(True, 0, 0, DRIVE, True), (False, 0, 0, DRIVE, False),
|
||||
(True, 100_000_000, 0, DRIVE, True), (True, 100_000_001, 0, DRIVE, False),
|
||||
(True, 0, -1, DRIVE, False), (True, 0, 0, DRIVE + 1, False)):
|
||||
message = publish_switchback(None, ModeIntent(DRIVE, True, True), session='a' * 32,
|
||||
sequence=2, now_ns=NOW, source_car_control_ns=NOW - 1)
|
||||
message.valid, message.logMonoTime = valid, NOW + stamp_delta
|
||||
with log.Event.from_bytes(message.to_bytes()) as event:
|
||||
generic, payload = SwitchbackStatusOwner(), SwitchbackStatusOwner()
|
||||
assert generic.sample(event, drive_id=drive, now_ns=NOW + age) is expected
|
||||
assert payload.sample_state(event.slcState, drive_id=drive, now_ns=NOW + age,
|
||||
event_ns=event.logMonoTime, valid=event.valid) is expected
|
||||
assert (payload.drive, payload.last, payload.retired) == (generic.drive, generic.last, generic.retired)
|
||||
if valid:
|
||||
bare = SwitchbackStatusOwner()
|
||||
assert bare.sample(event.slcState, drive_id=drive, now_ns=NOW + age, event_ns=event.logMonoTime) is expected
|
||||
assert (bare.drive, bare.last, bare.retired) == (payload.drive, payload.last, payload.retired)
|
||||
|
||||
|
||||
def test_switchback_payload_reader_preserves_duplicates_retirement_and_drive_reset():
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.controllers.mode_actions import ModeIntent, publish_switchback, SwitchbackStatusOwner
|
||||
|
||||
generic, payload = SwitchbackStatusOwner(), SwitchbackStatusOwner()
|
||||
for session, sequence, observed, drive, expected in (
|
||||
('a', 2, NOW, DRIVE, True), ('a', 2, NOW, DRIVE, True),
|
||||
('a', 1, NOW, DRIVE, False), ('b', 1, NOW + 1, DRIVE, False),
|
||||
('b', 1, NOW + 1, DRIVE, True), ('a', 2, NOW, DRIVE, False),
|
||||
('a', 2, NOW, 0, False), ('a', 2, NOW, DRIVE, True)):
|
||||
message = publish_switchback(None, ModeIntent(DRIVE, True, True), session=session * 32,
|
||||
sequence=sequence, now_ns=observed, source_car_control_ns=observed - 1)
|
||||
with log.Event.from_bytes(message.to_bytes()) as event:
|
||||
assert generic.sample(event, drive_id=drive, now_ns=NOW + 2) is expected
|
||||
assert payload.sample_state(event.slcState, drive_id=drive, now_ns=NOW + 2,
|
||||
event_ns=event.logMonoTime) is expected
|
||||
assert (payload.drive, payload.last, payload.retired) == (generic.drive, generic.last, generic.retired)
|
||||
|
||||
|
||||
def test_controller_dispatch_requires_live_producer_and_binds_current_control_stamp():
|
||||
from unittest.mock import Mock
|
||||
from openpilot.starpilot.controllers.mode_actions import ModeActionPublisher, ModeIntent, publish_switchback
|
||||
|
||||
@@ -42,17 +42,20 @@ def _row_action(row, apply, section):
|
||||
token, (lambda: apply(request)) if allowed else None, section)
|
||||
|
||||
|
||||
def mapped_actions(feature_snapshot, feature_apply, appearance_snapshot, appearance_apply):
|
||||
def mapped_actions(feature_snapshot, feature_apply, appearance_snapshot, appearance_apply, *, requested_keys=None):
|
||||
actions = {}
|
||||
for page, keys in FEATURE_KEYS.items():
|
||||
if requested_keys is not None and not requested_keys.intersection(keys):
|
||||
continue
|
||||
state = feature_snapshot(page)
|
||||
for row in state.rows:
|
||||
if row.key in keys:
|
||||
if row.key in keys and (requested_keys is None or row.key in requested_keys):
|
||||
actions[row.key] = _row_action(row, feature_apply, state.title)
|
||||
appearance = appearance_snapshot()
|
||||
for row in appearance.rows:
|
||||
if row.key in APPEARANCE_KEYS:
|
||||
actions[row.key] = _row_action(row, appearance_apply, "Visual")
|
||||
if requested_keys is None or requested_keys.intersection(APPEARANCE_KEYS):
|
||||
appearance = appearance_snapshot()
|
||||
for row in appearance.rows:
|
||||
if row.key in APPEARANCE_KEYS and (requested_keys is None or row.key in requested_keys):
|
||||
actions[row.key] = _row_action(row, appearance_apply, "Visual")
|
||||
actions[BOOKMARK] = FavoriteAction(BOOKMARK, "Bookmark", reason="Use this control on the device")
|
||||
actions[EXPERIMENTAL] = FavoriteAction(EXPERIMENTAL, "Experimental Mode", kind="toggle", state_label="On device",
|
||||
reason="Use this control on the device")
|
||||
@@ -64,4 +67,4 @@ def mapped_actions(feature_snapshot, feature_apply, appearance_snapshot, appeara
|
||||
(TRAFFIC, "Traffic Mode"), (SWITCHBACK, "Switchback Mode"), (SCREEN_OFF, "Toggle Screen Off"), (SELFIE, "Selfie")):
|
||||
actions[key] = FavoriteAction(key, label, kind="toggle" if key in (TRAFFIC, SWITCHBACK, SCREEN_OFF) else "action",
|
||||
reason="Use the qualified control on the device", section="Driving")
|
||||
return actions
|
||||
return actions if requested_keys is None else {key: action for key, action in actions.items() if key in requested_keys}
|
||||
|
||||
@@ -75,13 +75,21 @@ def read_slots(params):
|
||||
|
||||
|
||||
class FavoritesOwner:
|
||||
def __init__(self, params, actions: Callable[[], Mapping[str, FavoriteAction]], configurable: Callable[[], bool]):
|
||||
def __init__(self, params, actions: Callable[[], Mapping[str, FavoriteAction]], configurable: Callable[[], bool],
|
||||
render_actions: Callable[[frozenset[str]], Mapping[str, FavoriteAction]] | None = None):
|
||||
self.params, self.actions, self.configurable = params, actions, configurable
|
||||
self.render_actions = render_actions
|
||||
|
||||
def snapshot(self, *, include_options=True):
|
||||
saved, raw, readable, valid = read_slots(self.params)
|
||||
# Empty render slots need no action metadata until the picker is opened.
|
||||
options = self.actions() if include_options or any(slot["key"] is not None for slot in saved) else {}
|
||||
keys = frozenset(slot["key"] for slot in saved if slot["key"] is not None)
|
||||
if include_options:
|
||||
options = self.actions()
|
||||
elif keys:
|
||||
options = self.render_actions(keys) if self.render_actions is not None else self.actions()
|
||||
else:
|
||||
options = {}
|
||||
token = revision(raw)
|
||||
slots = []
|
||||
for index, slot in enumerate(saved):
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Native pure-projection cache ordering/concurrency, complementary to prior parity proof."""
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.longitudinal import toyota_output_policy as policy
|
||||
|
||||
|
||||
class TestRadarLeadProjectionCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with policy._RADAR_LEAD_LOCK:
|
||||
policy._RADAR_LEAD_ROWS.clear()
|
||||
self.kwargs = {'message_ns': 1_000_000_000, 'receipt_ns': 1_001_000_000,
|
||||
'now_ns': 1_002_000_000, 'drive_id': 900_000_000, 'valid': True}
|
||||
|
||||
def tearDown(self):
|
||||
with policy._RADAR_LEAD_LOCK:
|
||||
policy._RADAR_LEAD_ROWS.clear()
|
||||
|
||||
@staticmethod
|
||||
def radar(stamp, distance=10.):
|
||||
radar = log.RadarState.new_message()
|
||||
radar.mdMonoTime = stamp
|
||||
radar.leadOne.present = True
|
||||
radar.leadOne.dRel = distance
|
||||
return radar
|
||||
|
||||
@staticmethod
|
||||
def key(radar):
|
||||
return radar.as_reader().as_builder().to_bytes()
|
||||
|
||||
def test_exact_native_bytes_and_lru_recency(self):
|
||||
# All rows are equal; an unrelated native field still changes the exact key.
|
||||
radars = [self.radar(stamp) for stamp in range(9)]
|
||||
keys = [self.key(radar) for radar in radars]
|
||||
self.assertEqual(len(set(keys)), 9)
|
||||
for radar in radars[:8]:
|
||||
self.assertIsNotNone(policy.leads_from_radar(radar, **self.kwargs))
|
||||
policy.leads_from_radar(radars[0], **self.kwargs)
|
||||
policy.leads_from_radar(radars[8], **self.kwargs)
|
||||
with policy._RADAR_LEAD_LOCK:
|
||||
self.assertEqual(list(policy._RADAR_LEAD_ROWS), keys[2:8] + [keys[0], keys[8]])
|
||||
self.assertNotIn(keys[1], policy._RADAR_LEAD_ROWS)
|
||||
self.assertEqual(len(set(policy._RADAR_LEAD_ROWS.values())), 1)
|
||||
|
||||
def test_concurrent_native_projection_retains_locked_bounded_rows(self):
|
||||
lock = policy._RADAR_LEAD_LOCK
|
||||
|
||||
class GuardedRows(OrderedDict):
|
||||
# Assert every compound-operation primitive is inside the shared mutex.
|
||||
# The worker exercise also verifies concurrent output and retention bounds.
|
||||
def get(self, key, default=None):
|
||||
assert lock.locked()
|
||||
return super().get(key, default)
|
||||
|
||||
def move_to_end(self, key, last=True):
|
||||
assert lock.locked()
|
||||
return super().move_to_end(key, last)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
assert lock.locked()
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def popitem(self, last=True):
|
||||
assert lock.locked()
|
||||
return super().popitem(last)
|
||||
|
||||
rows = GuardedRows()
|
||||
start = threading.Barrier(6)
|
||||
|
||||
def project(worker):
|
||||
# Each thread owns its native builder; mutable builders are never shared.
|
||||
radar = self.radar(worker, worker + 1.)
|
||||
start.wait(timeout=10)
|
||||
for sequence in range(16):
|
||||
radar.mdMonoTime = worker * 100 + sequence
|
||||
for _ in range(5):
|
||||
leads = policy.leads_from_radar(radar, **self.kwargs)
|
||||
self.assertIsNotNone(leads)
|
||||
self.assertEqual(leads[0], policy.Lead(True, worker + 1., 0., 0., 0.))
|
||||
return True
|
||||
|
||||
with patch.object(policy, '_RADAR_LEAD_ROWS', rows):
|
||||
with ThreadPoolExecutor(max_workers=6) as workers:
|
||||
futures = [workers.submit(project, worker) for worker in range(6)]
|
||||
self.assertTrue(all(future.result(timeout=30) for future in futures))
|
||||
with lock:
|
||||
self.assertEqual(len(rows), 8)
|
||||
self.assertTrue(all(type(key) is bytes and len(key) <= 4096 for key in rows))
|
||||
self.assertTrue(all(type(value) is tuple and all(type(row) is tuple for row in value) for value in rows.values()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -5,14 +5,19 @@ validated lead data for Sienna and reset it whenever longitudinal control or
|
||||
source authority is lost. It does not select a planner profile or grant control.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
|
||||
import capnp
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
|
||||
from opendbc.car import structs
|
||||
from openpilot.cereal import log
|
||||
from opendbc.car.toyota.values import CAR, EPS_SCALE, ToyotaFlags, ToyotaSafetyFlags
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
|
||||
@@ -54,6 +59,18 @@ def leads_from_radar(radar, *, message_ns: int, receipt_ns: int, now_ns: int,
|
||||
not 0 < drive_id < message_ns <= receipt_ns <= now_ns or
|
||||
now_ns - message_ns > RADAR_MAX_AGE_NS or now_ns - receipt_ns > RADAR_MAX_AGE_NS):
|
||||
return None
|
||||
rows = _radar_lead_rows(radar)
|
||||
return tuple(Lead(*row) for row in rows) if rows is not None else None
|
||||
|
||||
|
||||
# Only immutable pure projection values are retained, never transport authority.
|
||||
_RADAR_LEAD_ROWS = OrderedDict()
|
||||
_RADAR_LEAD_LOCK = threading.Lock()
|
||||
_RADAR_LEAD_CACHE_MAX = 8
|
||||
_RADAR_LEAD_KEY_MAX_BYTES = 4096
|
||||
|
||||
|
||||
def _decode_radar_lead_rows(radar):
|
||||
try:
|
||||
if any(type(item.present) is not bool for item in (radar.leadOne, radar.leadTwo)):
|
||||
return None
|
||||
@@ -63,7 +80,36 @@ def leads_from_radar(radar, *, message_ns: int, receipt_ns: int, now_ns: int,
|
||||
return None
|
||||
if len(leads) != 2 or not ToyotaOutputPolicy._leads_valid(leads):
|
||||
return None
|
||||
return leads[0], leads[1]
|
||||
return tuple((lead.present, lead.distance_m, lead.lateral_m, lead.speed_mps, lead.accel_mps2) for lead in leads)
|
||||
|
||||
|
||||
def _radar_lead_rows(radar):
|
||||
# Custom fixtures retain their original decoding behavior. A builder is copied
|
||||
# on every call so mutation changes the key and cannot poison prior rows.
|
||||
if type(radar) not in (capnp._DynamicStructReader, capnp._DynamicStructBuilder):
|
||||
return _decode_radar_lead_rows(radar)
|
||||
try:
|
||||
if radar.schema != log.RadarState.schema:
|
||||
return _decode_radar_lead_rows(radar)
|
||||
snapshot = radar.as_builder() if type(radar) is capnp._DynamicStructReader else radar.as_reader().as_builder()
|
||||
key = snapshot.to_bytes()
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, capnp.KjException):
|
||||
return _decode_radar_lead_rows(radar)
|
||||
if len(key) > _RADAR_LEAD_KEY_MAX_BYTES:
|
||||
return _decode_radar_lead_rows(snapshot)
|
||||
with _RADAR_LEAD_LOCK:
|
||||
rows = _RADAR_LEAD_ROWS.get(key)
|
||||
if rows is not None:
|
||||
_RADAR_LEAD_ROWS.move_to_end(key)
|
||||
return rows
|
||||
rows = _decode_radar_lead_rows(snapshot)
|
||||
if rows is not None:
|
||||
with _RADAR_LEAD_LOCK:
|
||||
_RADAR_LEAD_ROWS[key] = rows
|
||||
_RADAR_LEAD_ROWS.move_to_end(key)
|
||||
if len(_RADAR_LEAD_ROWS) > _RADAR_LEAD_CACHE_MAX:
|
||||
_RADAR_LEAD_ROWS.popitem(last=False)
|
||||
return rows
|
||||
|
||||
|
||||
def eligible(cp) -> bool:
|
||||
|
||||
@@ -270,7 +270,7 @@ class StarShellSession:
|
||||
self._wheel_personality = None
|
||||
self.conditional_actions: ConditionalUiActionOwner | None = None
|
||||
self.cruise_actions = CruiseActionPublisher()
|
||||
self.favorites_owner = FavoritesOwner(ui_state.params, self._favorite_actions, self._favorite_authority)
|
||||
self.favorites_owner = FavoritesOwner(ui_state.params, self._favorite_actions, self._favorite_authority, self._favorite_render_actions)
|
||||
self.favorites = OnroadFavorites(self.fonts, self._favorite_request)
|
||||
self._favorite_snapshot = None
|
||||
self._favorite_data = None
|
||||
@@ -317,13 +317,19 @@ class StarShellSession:
|
||||
longitudinal_active=state.cruise_active, traffic_mode=state.traffic_mode)
|
||||
|
||||
def _favorite_actions(self):
|
||||
return self._resolve_favorite_actions()
|
||||
|
||||
def _favorite_render_actions(self, keys):
|
||||
return self._resolve_favorite_actions(keys)
|
||||
|
||||
def _resolve_favorite_actions(self, keys=None):
|
||||
actions = mapped_actions(lambda page: self.feature_snapshot(page, favorite=True), self.feature_request,
|
||||
lambda: self.appearance_owner.snapshot(self.profile), self.appearance_owner.apply)
|
||||
lambda: self.appearance_owner.snapshot(self.profile), self.appearance_owner.apply, requested_keys=keys)
|
||||
actions.update(layout_actions(ui_state.params, str(self.profile),
|
||||
lambda: self._settings_preference_authority() or self._favorite_authority(),
|
||||
lambda: self.adapter.invalidate_appearance()))
|
||||
actions.update(self._native_favorite_actions())
|
||||
return actions
|
||||
return actions if keys is None else {key: action for key, action in actions.items() if key in keys}
|
||||
|
||||
def native_favorite_actions(self, bookmark, personality, experimental):
|
||||
state = self.snapshot(ShellMode.ONROAD).onroad
|
||||
@@ -1216,7 +1222,8 @@ class StarShellSession:
|
||||
return self._snapshot_cache[1]
|
||||
snapshot = self.adapter.build(mode, self.selected, compact_y=self.compact_y,
|
||||
compact_scroll_x=self.compact_scroll_x, sidebar_expanded=self.sidebar_expanded,
|
||||
menu_only=self.profile == Profile.COMPACT and mode == ShellMode.SETTINGS)
|
||||
menu_only=self.profile == Profile.COMPACT and mode == ShellMode.SETTINGS,
|
||||
onroad_view_only=mode == ShellMode.ONROAD)
|
||||
if mode == ShellMode.ONROAD and self.profile == Profile.LARGE:
|
||||
width = getattr(self, '_onroad_page_width', 2160.0) - (SIDEBAR_WIDTH if snapshot.onroad.appearance.show_developer_sidebar else 0)
|
||||
if snapshot.onroad.viewport_width != width:
|
||||
|
||||
@@ -299,6 +299,7 @@ class RuntimeSnapshotAdapter:
|
||||
self.appearance_override: Callable[[OnroadAppearance], OnroadAppearance] | None = None
|
||||
self._last_mode: ShellMode | None = None
|
||||
self._onroad_ancillary: tuple[int, tuple[bool, int, bool], ShellSnapshot] | None = None
|
||||
self._onroad_view_ancillary: tuple[int, tuple[bool, int, bool], ShellSnapshot] | None = None
|
||||
self._conditional = ConditionalDisplayProjector()
|
||||
self._conditional_perception = ConditionalPerceptionProjector()
|
||||
self._conditional_choice: ModeChoice | None = None
|
||||
@@ -371,7 +372,8 @@ class RuntimeSnapshotAdapter:
|
||||
|
||||
def build(self, mode: ShellMode, selected: Destination = Destination.STAR, *, compact_y: float = 0,
|
||||
compact_scroll_x: float = 0, sidebar_expanded: bool = True, now_ns: int | None = None,
|
||||
menu_only: bool = False, stopped_timer_enabled: bool | None = None) -> ShellSnapshot:
|
||||
menu_only: bool = False, stopped_timer_enabled: bool | None = None,
|
||||
onroad_view_only: bool = False) -> ShellSnapshot:
|
||||
replay = getattr(self.ui_state, "replay_sample", None)
|
||||
if getattr(self.ui_state, "replay_clock", None) is not None:
|
||||
if replay is not None and replay.epoch != self._replay_epoch:
|
||||
@@ -383,6 +385,7 @@ class RuntimeSnapshotAdapter:
|
||||
now_ns = time.monotonic_ns() if now_ns is None else now_ns
|
||||
if mode != ShellMode.ONROAD or self._last_mode != ShellMode.ONROAD:
|
||||
self._onroad_ancillary = None
|
||||
self._onroad_view_ancillary = None
|
||||
if mode == ShellMode.ONROAD and self._last_mode == ShellMode.SETTINGS:
|
||||
self.invalidate_appearance()
|
||||
self._conditional_read_ns = None
|
||||
@@ -653,8 +656,9 @@ class RuntimeSnapshotAdapter:
|
||||
lead_indicator_source_fresh=display_model is not None and display_radar is not None)
|
||||
|
||||
ancillary_key = (bool(ui.started), int(ui.started_frame), confirmed_offroad)
|
||||
if mode == ShellMode.ONROAD and self._onroad_ancillary is not None:
|
||||
stamp, key, previous = self._onroad_ancillary
|
||||
ancillary = self._onroad_view_ancillary if onroad_view_only else self._onroad_ancillary
|
||||
if mode == ShellMode.ONROAD and ancillary is not None:
|
||||
stamp, key, previous = ancillary
|
||||
if key == ancillary_key and 0 <= now_ns - stamp < 1_000_000_000:
|
||||
return replace(previous, onroad=onroad)
|
||||
|
||||
@@ -708,6 +712,15 @@ class RuntimeSnapshotAdapter:
|
||||
if mode == ShellMode.HOME or menu_only and mode == ShellMode.SETTINGS:
|
||||
return ShellSnapshot(mode, home, settings, onroad, DeviceState(offroad=confirmed_offroad), selected=selected)
|
||||
|
||||
# The shell's onroad view/input consume onroad state, not settings leaf panels.
|
||||
# Keep current home/settings presentation and offroad evidence without querying
|
||||
# Galaxy credentials or constructing unconsumed device/software/toggle panels.
|
||||
if mode == ShellMode.ONROAD and onroad_view_only:
|
||||
snapshot = ShellSnapshot(mode, home, settings, onroad,
|
||||
DeviceState(offroad=confirmed_offroad, available_actions=frozenset()), selected=selected)
|
||||
self._onroad_view_ancillary = (now_ns, ancillary_key, snapshot)
|
||||
return snapshot
|
||||
|
||||
device_actions = {DeviceRequest.PREVIEW_DRIVER_CAMERA, DeviceRequest.RESET_CALIBRATION} if confirmed_offroad else set()
|
||||
galaxy_status = self.galaxy_access.status().status if self.galaxy_access is not None else AccessStatus.UNAVAILABLE
|
||||
if self.galaxy_access is not None:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.favorites.actions import APPEARANCE_KEYS, BOOKMARK, FEATURE_KEYS, mapped_actions
|
||||
from openpilot.starpilot.favorites.owner import FAVORITE_SLOTS_PARAM, FavoritesOwner, default_slots
|
||||
from openpilot.starpilot.favorites.state import FavoriteAction
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureRow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def selected_owner(tmp_path):
|
||||
path = tmp_path / FAVORITE_SLOTS_PARAM
|
||||
params = SimpleNamespace(get_param_path=lambda key: str(path))
|
||||
invoke = Mock(return_value=True)
|
||||
action = FavoriteAction(BOOKMARK, "Bookmark", available=True, token="fresh", invoke=invoke)
|
||||
catalog = Mock(return_value={BOOKMARK: action})
|
||||
selected = Mock(side_effect=lambda keys: {key: action for key, action in catalog.return_value.items() if key in keys})
|
||||
owner = FavoritesOwner(params, catalog, lambda: True, selected)
|
||||
return owner, catalog, selected, path, invoke
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keys", [(BOOKMARK,), (BOOKMARK, BOOKMARK), ("UnknownSavedControl",),
|
||||
(BOOKMARK, "UnknownSavedControl")])
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_selected_render_preserves_saved_slots_and_current_metadata(selected_owner, keys, enabled):
|
||||
owner, catalog, selected, path, invoke = selected_owner
|
||||
slots = default_slots()
|
||||
for index, key in enumerate(keys):
|
||||
slots[index].update(key=key, enabled=enabled, show_onroad=enabled, label="Saved label")
|
||||
raw = json.dumps(slots).encode()
|
||||
path.write_bytes(raw)
|
||||
full = owner.snapshot()
|
||||
rendered = owner.snapshot(include_options=False)
|
||||
assert rendered.slots == full.slots
|
||||
assert rendered.revision == full.revision
|
||||
assert (rendered.valid, rendered.configurable) == (full.valid, full.configurable)
|
||||
assert {action.key for action in rendered.options} == {action.key for action in full.options}.intersection(keys)
|
||||
selected.assert_called_once_with(frozenset(keys))
|
||||
assert catalog.call_count == 1
|
||||
assert path.read_bytes() == raw
|
||||
invoke.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, b"[]", b"bad-json", b'{"slots":[]}'])
|
||||
def test_empty_or_invalid_render_does_not_resolve_actions(selected_owner, raw):
|
||||
owner, catalog, selected, path, invoke = selected_owner
|
||||
if raw is not None:
|
||||
path.write_bytes(raw)
|
||||
rendered = owner.snapshot(include_options=False)
|
||||
assert rendered.options == ()
|
||||
catalog.assert_not_called()
|
||||
selected.assert_not_called()
|
||||
invoke.assert_not_called()
|
||||
assert not path.exists() if raw is None else path.read_bytes() == raw
|
||||
|
||||
|
||||
def test_unreadable_render_keeps_refusal(selected_owner):
|
||||
owner, catalog, selected, path, invoke = selected_owner
|
||||
path.mkdir()
|
||||
rendered = owner.snapshot(include_options=False)
|
||||
assert not rendered.valid and not rendered.configurable
|
||||
catalog.assert_not_called()
|
||||
selected.assert_not_called()
|
||||
invoke.assert_not_called()
|
||||
assert path.is_dir()
|
||||
|
||||
|
||||
def test_default_and_picker_snapshots_keep_full_catalog(selected_owner):
|
||||
owner, catalog, selected, _, _ = selected_owner
|
||||
assert owner.snapshot() == owner.snapshot(include_options=True)
|
||||
assert catalog.call_count == 2
|
||||
selected.assert_not_called()
|
||||
|
||||
|
||||
def test_owner_without_selected_resolver_retains_full_render_options(selected_owner):
|
||||
owner, catalog, _, path, _ = selected_owner
|
||||
owner.render_actions = None
|
||||
slots = default_slots()
|
||||
slots[0].update(key=BOOKMARK)
|
||||
path.write_text(json.dumps(slots))
|
||||
assert owner.snapshot(include_options=False) == owner.snapshot()
|
||||
assert catalog.call_count == 2
|
||||
|
||||
|
||||
def test_selected_render_request_rechecks_full_live_authority_token_and_revision(selected_owner):
|
||||
owner, catalog, selected, path, invoke = selected_owner
|
||||
slots = default_slots()
|
||||
slots[0].update(key=BOOKMARK, enabled=True, show_onroad=True)
|
||||
path.write_text(json.dumps(slots))
|
||||
request = owner.snapshot(include_options=False).slots[0].request
|
||||
assert request is not None
|
||||
catalog.return_value = {BOOKMARK: FavoriteAction(BOOKMARK, "Bookmark", available=False, token="fresh", invoke=invoke)}
|
||||
assert not owner.invoke(request).success
|
||||
catalog.return_value = {BOOKMARK: FavoriteAction(BOOKMARK, "Bookmark", available=True, token="changed", invoke=invoke)}
|
||||
assert not owner.invoke(request).success
|
||||
catalog.return_value = {BOOKMARK: FavoriteAction(BOOKMARK, "Bookmark", available=True, token="fresh", invoke=invoke)}
|
||||
path.write_bytes(path.read_bytes() + b"\n")
|
||||
assert not owner.invoke(request).success
|
||||
assert selected.call_count == 1
|
||||
assert catalog.call_count == 2
|
||||
invoke.assert_not_called()
|
||||
|
||||
|
||||
def test_selected_resolver_errors_are_not_hidden(selected_owner):
|
||||
owner, catalog, selected, path, _ = selected_owner
|
||||
slots = default_slots()
|
||||
slots[0].update(key=BOOKMARK)
|
||||
path.write_text(json.dumps(slots))
|
||||
selected.side_effect = OSError("saved source unavailable")
|
||||
with pytest.raises(OSError, match="saved source unavailable"):
|
||||
owner.snapshot(include_options=False)
|
||||
catalog.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keys", [frozenset([BOOKMARK]), frozenset(["LaneCentering"]),
|
||||
frozenset(["CameraView"]), frozenset(["UnknownSavedControl"]),
|
||||
frozenset(["LaneCentering", "CameraView"])])
|
||||
def test_mapped_selection_preserves_current_actions_and_skips_unrelated_pages(keys):
|
||||
def row(key):
|
||||
return FeatureRow(key, key, "Off", choices=("Off", "On"), available=True)
|
||||
pages = {page: SimpleNamespace(title=page, rows=tuple(row(key) for key in names))
|
||||
for page, names in FEATURE_KEYS.items()}
|
||||
feature = Mock(side_effect=lambda page: pages[page])
|
||||
appearance = Mock(return_value=SimpleNamespace(rows=tuple(row(key) for key in APPEARANCE_KEYS)))
|
||||
apply = Mock()
|
||||
full = mapped_actions(feature, apply, appearance, apply)
|
||||
feature.reset_mock()
|
||||
appearance.reset_mock()
|
||||
rendered = mapped_actions(feature, apply, appearance, apply, requested_keys=keys)
|
||||
assert set(rendered) == set(full).intersection(keys)
|
||||
for key, action in rendered.items():
|
||||
expected = full[key]
|
||||
assert (action.label, action.available, action.token, action.reason, action.state_label) == (
|
||||
expected.label, expected.available, expected.token, expected.reason, expected.state_label)
|
||||
assert (action.invoke is None) == (expected.invoke is None)
|
||||
assert feature.call_count == sum(bool(keys.intersection(names)) for names in FEATURE_KEYS.values())
|
||||
assert appearance.call_count == int(bool(keys.intersection(APPEARANCE_KEYS)))
|
||||
apply.assert_not_called()
|
||||
@@ -0,0 +1,110 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from openpilot.starpilot.ui.settings_state import Destination
|
||||
from openpilot.starpilot.ui.shell import ShellMode
|
||||
from openpilot.starpilot.ui.tests.test_runtime_snapshot import NOW, RuntimeSnapshotAdapter, ui_fake
|
||||
from openpilot.tools.replay.display_clock import ClockSample
|
||||
|
||||
|
||||
class TestOnroadLeafScope(unittest.TestCase):
|
||||
def test_opt_in_preserves_consumed_state_and_skips_leaf_queries(self):
|
||||
full_ui, view_ui = ui_fake(), ui_fake()
|
||||
full = RuntimeSnapshotAdapter(full_ui)
|
||||
view = RuntimeSnapshotAdapter(view_ui, galaxy_access=SimpleNamespace(status=Mock(side_effect=AssertionError("unused Galaxy query"))))
|
||||
forbidden = {"DongleId", "HardwareSerial", "UpdaterState", "UpdaterFetchAvailable", "DisableUpdates",
|
||||
"UpdaterTargetBranch", "OpenpilotEnabledToggle", "DisengageOnAccelerator", "IsLdwEnabled", "AlwaysOnDM", "RecordFront", "RecordAudio"}
|
||||
get, get_bool = view_ui.params.get, view_ui.params.get_bool
|
||||
def checked_get(key):
|
||||
self.assertNotIn(key, forbidden)
|
||||
return get(key)
|
||||
def checked_bool(key):
|
||||
self.assertNotIn(key, forbidden)
|
||||
return get_bool(key)
|
||||
view_ui.params.get, view_ui.params.get_bool = checked_get, checked_bool
|
||||
for frame in range(5):
|
||||
now = NOW + frame * 600_000_000
|
||||
for ui in (full_ui, view_ui):
|
||||
ui.sm.frame += 1
|
||||
ui.params.values["ShowCSCStatus"] = str(frame % 2)
|
||||
expected = full.build(ShellMode.ONROAD, now_ns=now)
|
||||
actual = view.build(ShellMode.ONROAD, now_ns=now, onroad_view_only=True)
|
||||
self.assertEqual(actual.onroad, expected.onroad)
|
||||
self.assertEqual(actual.home, expected.home)
|
||||
self.assertEqual(actual.settings, expected.settings)
|
||||
self.assertEqual(actual.device.offroad, expected.device.offroad)
|
||||
self.assertEqual(actual.selected, expected.selected)
|
||||
view.galaxy_access.status.assert_not_called()
|
||||
|
||||
def test_narrow_cache_cannot_replace_default_full_snapshot(self):
|
||||
ui = ui_fake()
|
||||
ui.params.values["DongleId"] = "actual-device"
|
||||
adapter = RuntimeSnapshotAdapter(ui)
|
||||
narrow = adapter.build(ShellMode.ONROAD, now_ns=NOW, onroad_view_only=True)
|
||||
full = adapter.build(ShellMode.ONROAD, now_ns=NOW)
|
||||
self.assertNotEqual(narrow.device.dongle_id, full.device.dongle_id)
|
||||
self.assertEqual(full.device.dongle_id, "actual-device")
|
||||
self.assertEqual(narrow.onroad, full.onroad)
|
||||
live = adapter.build(ShellMode.ONROAD, now_ns=NOW + 1, onroad_view_only=True)
|
||||
self.assertEqual(live.onroad.observed_ns, NOW + 1)
|
||||
self.assertEqual(narrow.onroad.observed_ns, NOW)
|
||||
self.assertIsNot(live.onroad, narrow.onroad)
|
||||
self.assertFalse(narrow.device.available_actions)
|
||||
self.assertIsNotNone(adapter._onroad_ancillary)
|
||||
self.assertIsNotNone(adapter._onroad_view_ancillary)
|
||||
|
||||
def test_settings_and_home_rebuild_immediately_after_onroad(self):
|
||||
ui = ui_fake()
|
||||
adapter = RuntimeSnapshotAdapter(ui)
|
||||
adapter.build(ShellMode.ONROAD, now_ns=NOW, onroad_view_only=True)
|
||||
ui.params.values.update(DongleId="new-device", UpdaterState="downloading", GitCommit="new-commit")
|
||||
settings = adapter.build(ShellMode.SETTINGS, Destination.DEVICE, now_ns=NOW + 1, onroad_view_only=True)
|
||||
self.assertEqual(settings.device.dongle_id, "new-device")
|
||||
self.assertEqual(settings.software.download_status, "downloading")
|
||||
self.assertIsNone(adapter._onroad_view_ancillary)
|
||||
home = adapter.build(ShellMode.HOME, now_ns=NOW + 2, onroad_view_only=True)
|
||||
self.assertEqual(home.home.commit, "new-commit")
|
||||
adapter.build(ShellMode.ONROAD, now_ns=NOW + 3, onroad_view_only=True)
|
||||
self.assertIsNotNone(adapter._onroad_view_ancillary)
|
||||
|
||||
def test_onroad_key_change_recomputes_fresh_parked_evidence(self):
|
||||
ui = ui_fake()
|
||||
adapter = RuntimeSnapshotAdapter(ui)
|
||||
first = adapter.build(ShellMode.ONROAD, now_ns=NOW, onroad_view_only=True)
|
||||
cached = adapter._onroad_view_ancillary
|
||||
ui.started_frame += 1
|
||||
ui.sm.frame += 1
|
||||
second = adapter.build(ShellMode.ONROAD, now_ns=NOW + 1, onroad_view_only=True)
|
||||
self.assertIsNot(adapter._onroad_view_ancillary, cached)
|
||||
self.assertFalse(first.device.offroad)
|
||||
self.assertFalse(second.device.offroad)
|
||||
self.assertFalse(second.device.available_actions)
|
||||
|
||||
def test_replay_epoch_reinitializes_narrow_cache_and_temporal_projectors(self):
|
||||
ui = ui_fake()
|
||||
ui.replay_clock = object()
|
||||
ui.replay_sample = ClockSample(NOW, None, NOW * 1000, epoch=1, valid=True)
|
||||
ui.sm.replay_sample = ui.replay_sample
|
||||
adapter = RuntimeSnapshotAdapter(ui)
|
||||
first = adapter.build(ShellMode.ONROAD, onroad_view_only=True)
|
||||
cache, projector = adapter._onroad_view_ancillary, adapter._conditional
|
||||
ui.replay_sample = ClockSample(NOW - 1_000_000_000, None, NOW * 1000, epoch=2, valid=True)
|
||||
ui.sm.replay_sample = ui.replay_sample
|
||||
second = adapter.build(ShellMode.ONROAD, onroad_view_only=True)
|
||||
self.assertIsNot(adapter._onroad_view_ancillary, cache)
|
||||
self.assertIsNot(adapter._conditional, projector)
|
||||
self.assertFalse(first.device.offroad)
|
||||
self.assertFalse(second.device.offroad)
|
||||
self.assertFalse(second.onroad.engaged)
|
||||
|
||||
def test_full_onroad_cache_does_not_suppress_narrow_refresh_or_settings(self):
|
||||
ui = ui_fake()
|
||||
adapter = RuntimeSnapshotAdapter(ui)
|
||||
adapter.build(ShellMode.ONROAD, now_ns=NOW)
|
||||
adapter.build(ShellMode.ONROAD, now_ns=NOW + 1, onroad_view_only=True)
|
||||
ui.params.values["UpdaterState"] = "downloading"
|
||||
full = adapter.build(ShellMode.SETTINGS, Destination.SOFTWARE, now_ns=NOW + 2)
|
||||
self.assertEqual(full.software.download_status, "downloading")
|
||||
self.assertIsNone(adapter._onroad_view_ancillary)
|
||||
self.assertIsNone(adapter._onroad_ancillary)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
extern const uint8_t gitversion[29];
|
||||
const uint8_t gitversion[29] = "DEV-39689cd6-P92eb5651-DEBUG";
|
||||
const uint8_t gitversion[29] = "DEV-e9829871-P92eb5651-DEBUG";
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
DEV-39689cd6-P92eb5651-DEBUG
|
||||
DEV-e9829871-P92eb5651-DEBUG
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "d2d6d6688177e81187cdf55c32b9fe2bbf198f72c77e66c8cc1d5561ee5dbd4d", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "57bf4bf680d75776c53c5e64a459500d3743fd8c850d14e89bd9ab2f432d5f0c", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user