from collections import deque from pathlib import Path import time from types import SimpleNamespace as NS import pytest from openpilot.cereal import log, messaging from openpilot.common.params import Params from openpilot.starpilot.controllers.cruise_action import CruiseActionPublisher from openpilot.starpilot.controllers.mode_actions import ModeActionPublisher, ModeIntent, publish_switchback from openpilot.starpilot.controllers.tests.test_wheel_actions import Publisher, candidate, services, DRIVE, NOW from openpilot.starpilot.controllers.wheel_actions import WheelPublisher, WheelConsumer from openpilot.starpilot.favorites.actions import BOOKMARK, SET_SPEED, TRAFFIC, FORCE_COAST from openpilot.starpilot.favorites.owner import FavoritesOwner, default_slots from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation from openpilot.starpilot.ui.shell import ShellMode @pytest.fixture def native_session(tmp_path, monkeypatch): from openpilot.starpilot.ui import runtime_app from openpilot.selfdrive.ui import ui_state as module params = Params(str(tmp_path)) _, cp = candidate(False) sm = services(cp) sm['selfdriveState'] = NS(enabled=True) status = publish_switchback(None, ModeIntent(DRIVE, False, False), session='b' * 32, sequence=1, now_ns=NOW, source_car_control_ns=NOW) sm['slcState'] = status.slcState for key in ('selfdriveState', 'slcState'): sm.logMonoTime[key], sm.recv_time[key] = NOW, NOW / 1e9 sm.seen[key] = sm.alive[key] = sm.valid[key] = True ui = NS(CP=cp, sm=sm, params=params, has_longitudinal_control=True, personality=0, started_frame=1) monkeypatch.setattr(runtime_app, 'ui_state', ui) monkeypatch.setattr(runtime_app.time, 'monotonic_ns', lambda: NOW) monkeypatch.setattr(module, 'device', NS(awake=True, toggle_screen_off=lambda: True)) sender = Publisher() monkeypatch.setattr(runtime_app, '_slc_action_publisher', lambda: sender) session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session._mode = ShellMode.ONROAD state = OnroadState(True, True, 15, 80, SpeedLimitObservation()) vars(session)['snapshot'] = lambda *_: NS(onroad=state) vars(session)['_favorite_authority'] = lambda **_: True vars(session)['_conditional_favorite_active'] = lambda: False session.mode_actions, session.cruise_actions = ModeActionPublisher(), CruiseActionPublisher() session._wheel_personality = None session._wheel_consumer, session._wheel_pending = WheelConsumer(), deque(maxlen=32) session._wheel_sock = object() session.slc_actions = None calls = [] session._native_favorite_actions = lambda: session.native_favorite_actions(lambda: calls.append('bookmark') or True, lambda value: calls.append(value), lambda: True) session.favorites_owner = FavoritesOwner(params, session._native_favorite_actions, lambda: True) return runtime_app, session, ui, sender, calls def test_actual_favorite_value_callback_reads_units_and_publishes_absolute_receipt(native_session): _, session, ui, sender, _ = native_session ui.params.put('IsMetric', True, block=True) slots = default_slots() slots[0].update(enabled=True, key=SET_SPEED, label='Set speed', value=55) owner = session.favorites_owner owner.save(slots, owner.snapshot().revision) request = owner.snapshot().slots[0].request assert owner.invoke(request).success event = messaging.log_from_bytes(sender.events[-1][1]) assert str(event.slcAction.kind) == 'cruiseSet' assert event.slcAction.controllerCruise.targetSpeedMps == pytest.approx(55 / 3.6) vars(session)['_favorite_authority'] = lambda **_: False assert not owner.invoke(owner.snapshot().slots[0].request).success assert len(sender.events) == 1 @pytest.mark.parametrize('metric', [False, True]) def test_galaxy_speed_edit_reaches_native_callback_without_activation_or_stale_overwrite(native_session, metric): from openpilot.starpilot.galaxy.favorites import FavoritesGateway from openpilot.starpilot.galaxy.settings import AuthorityContext _, session, ui, sender, _ = native_session ui.params.put_bool('IsMetric', metric, block=True) slots = default_slots() slots[0].update(enabled=True, show_onroad=False, key=SET_SPEED, label='Cruise', value=30) slots[1].update(enabled=True, show_onroad=True, key=BOOKMARK, label='Mark') owner = session.favorites_owner owner.save(slots, owner.snapshot().revision) old_request = owner.snapshot().slots[0].request gateway = FavoritesGateway(ui.params, NS(sample=lambda: AuthorityContext(False, None, None))) try: data = gateway.snapshot() data['slots'][0]['value'] = 45 saved = gateway.save({'revision': data['revision'], 'slots': data['slots']}, session_valid=lambda: True) assert saved['slots'] == [{**slots[0], 'value': 45}, *slots[1:]] assert gateway.snapshot()['slots'] == saved['slots'] assert sender.events == [] assert not owner.invoke(old_request).success assert sender.events == [] assert owner.invoke(owner.snapshot().slots[0].request).success event = messaging.log_from_bytes(sender.events[-1][1]) assert str(event.slcAction.kind) == 'cruiseSet' assert event.slcAction.controllerCruise.targetSpeedMps == pytest.approx(45 / 3.6 if metric else 45 * .44704) finally: gateway.close() def test_card_receipt_invokes_real_native_bookmark_and_quick_select_callback(native_session, monkeypatch): runtime, session, ui, _, calls = native_session source, publisher = WheelPublisher(ui.params), Publisher() queue = [] monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) for sequence, code in enumerate((8, 11), start=1): ui.params.put('DistanceButtonControl', code, block=True) if code == 11: owner = session.favorites_owner assert owner.configure_index(0, BOOKMARK, owner.snapshot().revision) source.publish((('DistanceButtonControl', code),), ui.CP, publisher, now_ns=NOW, drive_id=DRIVE, source_car_ns=NOW, source_control_ns=NOW) queue.append(messaging.log_from_bytes(publisher.events[-1][1])) session._poll_wheel(NOW) assert len(calls) == sequence assert calls == ['bookmark', 'bookmark'] def test_actual_native_traffic_and_coast_callbacks_emit_owner_requests(native_session): _, session, _, sender, _ = native_session actions = session._native_favorite_actions() assert actions[TRAFFIC].invoke() session.mode_actions.last_ns = 0 assert actions[FORCE_COAST].invoke() assert [str(messaging.log_from_bytes(raw).slcAction.kind) for _, raw in sender.events] == [ 'trafficModeToggle', 'forceCoastToggle'] @pytest.mark.parametrize('vehicle,filtered', [('TOYOTA_PRIUS', True), ('TOYOTA_SIENNA_4TH_GEN', False)]) def test_toyota_default_gap_callback_saves_personality_once(native_session, monkeypatch, vehicle, filtered): from opendbc.car import structs from opendbc.car.toyota.interface import CarInterface from opendbc.car.toyota.values import CAR runtime, session, ui, _, _ = native_session rack = structs.CarParams.CarFw.new_message(ecu=structs.CarParams.Ecu.eps, fwVersion=b'8965B47070\x00\x00\x00\x00\x00\x00') ui.CP = CarInterface.get_params(getattr(CAR, vehicle), {0: {0x2FF: 8} if filtered else {}, 1: {}, 2: {}}, [rack] if filtered else [], False, False, False) ui.has_longitudinal_control = ui.CP.openpilotLongitudinalControl ui.personality = 1 def save_personality(value): ui.params.put('LongitudinalPersonality', value, block=True) ui.personality = value return True session._native_favorite_actions = lambda: session.native_favorite_actions(lambda: True, save_personality, lambda: True) queue = [] monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) owner, publisher = WheelPublisher(), Publisher() from openpilot.starpilot.controllers.tests.test_wheel_actions import state owner.observe(ui.params, ui.CP, state(), now_ns=NOW - 20_000_000, drive_id=DRIVE) owner.observe(ui.params, ui.CP, state(True), now_ns=NOW - 10_000_000, drive_id=DRIVE) commands = owner.observe(ui.params, ui.CP, state(False), now_ns=NOW, drive_id=DRIVE) assert commands == (('DistanceButtonControl', 1),) assert owner.suppress_distance_release owner.publish(commands, ui.CP, publisher, now_ns=NOW, drive_id=DRIVE, source_car_ns=NOW, source_control_ns=NOW) queue.append(messaging.log_from_bytes(publisher.events[-1][1])) session._poll_wheel(NOW) assert ui.params.get('LongitudinalPersonality') == 0 session._poll_wheel(NOW) assert ui.params.get('LongitudinalPersonality') == 0 @pytest.fixture def personality_session(native_session, monkeypatch): from openpilot.selfdrive.ui import ui_state as module runtime, session, ui, _, _ = native_session ui.started = ui.engaged = ui._started_prev = ui._engaged_prev = True ui._engaged_transition_callbacks = [] ui._offroad_transition_callbacks = [] ui.add_engaged_transition_callback = ui._engaged_transition_callbacks.append ui.update_params = lambda: None ui.sm.frame = 100 ui.sm.seen = dict(ui.sm.seen) ui.sm.alive = dict(ui.sm.alive) ui.sm.valid = dict(ui.sm.valid) ui.sm.updated = dict.fromkeys(ui.sm, True) ui.sm.recv_frame = dict.fromkeys(ui.sm, ui.sm.frame) ui.sm['selfdriveState'] = log.SelfdriveState.new_message(enabled=True, state='enabled', personality='standard') ui.params.put('LongitudinalPersonality', 1, block=True) ui.personality = 1 clock = [NOW] monkeypatch.setattr(runtime.time, 'monotonic_ns', lambda: clock[0]) return runtime, session, ui, module, clock @pytest.mark.parametrize('profile', ['c4', 'c3']) def test_hidden_native_personality_owner_completes_three_distance_cycles(personality_session, monkeypatch, profile): import pyray as rl from openpilot.selfdrive.ui.layouts.settings import common from openpilot.selfdrive.ui.layouts.settings import toggles as large from openpilot.selfdrive.ui.mici.layouts.settings import toggles as compact from openpilot.selfdrive.ui.mici.widgets import button from openpilot.starpilot.controllers.tests.test_wheel_actions import state from openpilot.system.ui.lib.application import gui_app runtime, session, ui, module, clock = personality_session monkeypatch.setattr(common, 'ui_state', ui) monkeypatch.setattr(large, 'ui_state', ui) monkeypatch.setattr(compact, 'ui_state', ui) monkeypatch.setattr(large, 'Params', lambda: ui.params) monkeypatch.setattr(button, 'Params', lambda: ui.params) monkeypatch.setattr(gui_app, 'font', lambda *_: rl.Font()) monkeypatch.setattr(gui_app, 'texture', lambda *_: NS(width=64, height=64)) panel = compact.TogglesLayoutMici() if profile == 'c4' else large.TogglesLayout() panel.set_visible(False) session._native_favorite_actions = lambda: session.native_favorite_actions( lambda: True, panel.request_personality, lambda: True) queue = [] monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) source, publisher = WheelPublisher(ui.params), Publisher() source.observe(ui.params, ui.CP, state(), now_ns=NOW - 20_000_000, drive_id=DRIVE) for sequence, expected in enumerate((0, 2, 1), start=1): clock[0] = NOW + (sequence - 1) * 200_000_000 ui.sm.frame += 1 for name in ui.sm: ui.sm.logMonoTime[name] = clock[0] ui.sm.recv_time[name] = clock[0] / 1e9 ui.sm.recv_frame[name] = ui.sm.frame source.observe(ui.params, ui.CP, state(True), now_ns=clock[0] - 10_000_000, drive_id=DRIVE) commands = source.observe(ui.params, ui.CP, state(False), now_ns=clock[0], drive_id=DRIVE) assert commands == (('DistanceButtonControl', 1),) assert source.suppress_distance_release source.publish(commands, ui.CP, publisher, now_ns=clock[0], drive_id=DRIVE, source_car_ns=clock[0], source_control_ns=clock[0]) wire = messaging.log_from_bytes(publisher.events[-1][1]) assert wire.slcCruiseEvent.wheelAction.sequence == sequence queue.append(wire) session._poll_wheel(clock[0]) deadline = time.monotonic() + 2 while ui.params.get('LongitudinalPersonality', return_default=True) != expected and time.monotonic() < deadline: time.sleep(0.005) saved = ui.params.get('LongitudinalPersonality', return_default=True) assert saved == expected # Mirror Selfdrived.params_thread -> publish_selfdriveState, then run the # production UI update. The hidden settings panel never renders or updates. ui.sm['selfdriveState'].personality = saved module.UIState._update_status(ui) assert int(ui.personality) == expected assert not panel.is_visible session._poll_wheel(clock[0]) assert ui.params.get('LongitudinalPersonality', return_default=True) == expected @pytest.mark.parametrize('case', ['fresh', 'stale', 'future', 'invalid', 'dead', 'before_drive', 'offroad', 'not_updated']) def test_personality_cache_rejects_unusable_selfdrive_state(personality_session, case): _, _, ui, module, _ = personality_session ui.sm['selfdriveState'].personality = 'aggressive' if case == 'stale': ui.sm.logMonoTime['selfdriveState'] = NOW - 250_000_001 elif case == 'future': ui.sm.logMonoTime['selfdriveState'] = NOW + 1 elif case == 'invalid': ui.sm.valid['selfdriveState'] = False elif case == 'dead': ui.sm.alive['selfdriveState'] = False elif case == 'before_drive': ui.sm.recv_frame['selfdriveState'] = ui.started_frame elif case == 'offroad': ui.started = ui.engaged = False elif case == 'not_updated': ui.sm.updated['selfdriveState'] = False module.UIState._update_status(ui) assert int(ui.personality) == (0 if case == 'fresh' else 1) @pytest.mark.parametrize('profile', ['c4', 'c3']) def test_rapid_personality_cycles_accept_own_notice_and_reject_safety_alert(personality_session, monkeypatch, profile): import pyray as rl from dataclasses import replace from openpilot.selfdrive.ui.layouts.settings import common from openpilot.selfdrive.ui.layouts.settings import toggles as large from openpilot.selfdrive.ui.mici.layouts.settings import toggles as compact from openpilot.selfdrive.ui.mici.widgets import button from openpilot.starpilot.controllers.tests.test_wheel_actions import state from openpilot.starpilot.favorites.actions import CYCLE_PERSONALITY, EXPERIMENTAL from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert from openpilot.starpilot.ui.presentation import Profile from openpilot.system.ui.lib.application import gui_app runtime, session, ui, _, clock = personality_session for module in (common, large, compact): monkeypatch.setattr(module, 'ui_state', ui) monkeypatch.setattr(large, 'Params', lambda: ui.params) monkeypatch.setattr(button, 'Params', lambda: ui.params) monkeypatch.setattr(gui_app, 'font', lambda *_: rl.Font()) monkeypatch.setattr(gui_app, 'texture', lambda *_: NS(width=64, height=64)) panel = compact.TogglesLayoutMici() if profile == 'c4' else large.TogglesLayout() panel.set_visible(False) session.profile = Profile.COMPACT if profile == 'c4' else Profile.LARGE del vars(session)['_favorite_authority'] # Exercise the real runtime admission. shown = [OnroadState(True, True, 15, 80, SpeedLimitObservation(), alert=OnroadAlert(AlertSize.MID, 'Standard', 'Driving Personality', alert_type='personalityChanged/warning'))] vars(session)['snapshot'] = lambda *_: NS(onroad=shown[0]) session._native_favorite_actions = lambda: session.native_favorite_actions( lambda: True, panel.request_personality, lambda: True) actions = session._native_favorite_actions() assert actions[CYCLE_PERSONALITY].available and actions[CYCLE_PERSONALITY].invoke() assert ui.params.get('LongitudinalPersonality') == 2 assert not actions[BOOKMARK].available and not actions[EXPERIMENTAL].available assert not actions[BOOKMARK].invoke() and not actions[EXPERIMENTAL].invoke() queue = [] monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) source, publisher = WheelPublisher(ui.params), Publisher() source.observe(ui.params, ui.CP, state(), now_ns=NOW - 20_000_000, drive_id=DRIVE) for sequence, expected in enumerate((1, 0, 2), start=1): clock[0] = NOW + sequence * 5_000_000 for name in ui.sm: ui.sm.logMonoTime[name], ui.sm.recv_time[name] = clock[0], clock[0] / 1e9 source.observe(ui.params, ui.CP, state(True), now_ns=clock[0] - 1_000_000, drive_id=DRIVE) commands = source.observe(ui.params, ui.CP, state(False), now_ns=clock[0], drive_id=DRIVE) assert commands == (('DistanceButtonControl', 1),) source.publish(commands, ui.CP, publisher, now_ns=clock[0], drive_id=DRIVE, source_car_ns=clock[0], source_control_ns=clock[0]) queue.append(messaging.log_from_bytes(publisher.events[-1][1])) session._poll_wheel(clock[0]) assert ui.params.get('LongitudinalPersonality') == expected assert ui.personality == 1 # No selfdriveState acknowledgement between presses. shown[0] = replace(shown[0], alert=OnroadAlert(AlertSize.FULL, 'Take control', critical=True, alert_type='controlsMismatch/immediateDisable')) assert not actions[CYCLE_PERSONALITY].invoke() source.publish((('DistanceButtonControl', 1),), ui.CP, publisher, now_ns=clock[0], drive_id=DRIVE, source_car_ns=clock[0], source_control_ns=clock[0]) queue.append(messaging.log_from_bytes(publisher.events[-1][1])) session._poll_wheel(clock[0]) assert ui.params.get('LongitudinalPersonality') == 2 shown[0] = replace(shown[0], alert=OnroadAlert()) ui.params.put_bool('SafeMode', True, block=True) assert not actions[CYCLE_PERSONALITY].invoke() ui.params.put_bool('SafeMode', False, block=True) path = Path(ui.params.get_param_path('LongitudinalPersonality')) path.write_bytes(b'bad') assert not actions[CYCLE_PERSONALITY].invoke() assert path.read_bytes() == b'bad'