"""UI request messages through the real SLC decision runtime, with no IPC.""" from dataclasses import replace from types import SimpleNamespace as NS import unittest from unittest.mock import Mock from opendbc.car.honda.interface import CarInterface from opendbc.car.honda.values import CAR import openpilot.cereal.messaging as messaging from openpilot.starpilot.longitudinal.tests.test_cruise_ceiling import messages from openpilot.starpilot.speed_limits import acceptance as acc from openpilot.starpilot.speed_limits.runtime import Action, Runtime from openpilot.starpilot.speed_limits.runtime_settings import parse from openpilot.starpilot.speed_limits.tests.test_runtime_replay import ReplaySM from openpilot.starpilot.ui.onroad_state import (OnroadInput, OnroadState, SlcActionKind, SlcUiRequest, slc_controls, speed_limit_from_message, speed_limit_bounds) from openpilot.starpilot.ui.presentation import Profile from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher START = 2_000_000_000 def replay_inputs() -> tuple[ReplaySM, object]: cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) data, _ = messages() source = messaging.new_message("slcDashboardObservation").slcDashboardObservation data["slcDashboardObservation"] = source sm = ReplaySM(data, START) car = sm["carState"] car.vCruise = 60.0 car.vCruiseCluster = 60.0 car.vEgoCluster = 20.0 sm["carControl"].enabled = True sm["carControl"].longActive = True source.producerSessionId = "card-producer" source.carStateLogMonoTime = START source.status = "valid" source.speedMps = 25.0 source.observedMonoTime = START - 100_000_000 source.validUntilMonoTime = START + 5_000_000_000 source.episode = 1 return sm, cp def ui_request(message, *, profile: Profile, kind: SlcActionKind) -> SlcUiRequest: observation = speed_limit_from_message(message.slcState) view_state = OnroadState(True, False, 20.0, 60.0, observation, longitudinal_active=True, slc_system_long_available=True) emitted: list[SlcUiRequest] = [] def emit(request): if not isinstance(request, SlcUiRequest): raise AssertionError("Unexpected non-SLC touch request") emitted.append(request) touch = OnroadInput(emit, profile) if observation.pending_speed_limit_mps is not None: bounds = speed_limit_bounds(profile, view_state) assert bounds is not None left, top, right, bottom = bounds x, y = (left + right) / 2, (top + bottom) / 2 touch.press(x, y, view_state) touch.release(x, y, view_state) control = next(item for item in slc_controls(profile, touch.present(view_state)) if item.request.kind == kind) left, top, right, bottom = control.bounds x, y = (left + right) / 2, (top + bottom) / 2 touch.press(x, y, view_state) touch.release(x, y, view_state) if len(emitted) != 1: raise AssertionError("Visible SLC touch emitted no request") return emitted[0] def publish_request(state, request: SlcUiRequest, now_ns: int): sent = [] dispatcher = SlcActionDispatcher(NS(send=lambda service, message: sent.append((service, message))), lambda _: state, lambda: now_ns) if not dispatcher.dispatch(request): raise AssertionError("Current SLC presentation rejected the UI request") service, message = sent[0] if service != "slcAction": raise AssertionError("Wrong SLC action service") action = message.slcAction return Action(str(action.sessionId), int(action.sequenceId), int(action.decisionId), int(action.presentationId), str(action.kind)) def accepted_runtime(sm, cp, settings, *, session_id): runtime = Runtime(settings, session_id=session_id) candidate = next(obs.candidate for obs in runtime._observations(sm, cp, START).values() if obs.candidate is not None) accepted = acc.AcceptedLimit(candidate, session_id, 0) runtime.state = replace(runtime.state, acceptance=replace(acc.new_session(session_id, accepted), received_speed_mps=candidate.speed_mps)) return runtime class TestSlcUiRuntime(unittest.TestCase): def test_confirmation_requires_current_presentation_through_source_handoff(self): for profile in Profile: for kind in (SlcActionKind.ACCEPT, SlcActionKind.REJECT): for change in ('none', 'wrong_id', 'earlier_handoff', 'same_step_handoff', 'new_limit'): with self.subTest(profile=profile, kind=kind, change=change): sm, cp = replay_inputs() runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True, 'SLCPriority1': 'Dashboard'})) pending = runtime.step(sm, cp, now_ns=START).message request = ui_request(pending, profile=profile, kind=kind) action = publish_request(pending.slcState, request, START + 10_000_000) if change == 'wrong_id': action = replace(action, presentation_id=action.presentation_id + 1000) elif change != 'none': sm['slcDashboardObservation'].episode += 1 sm['slcDashboardObservation'].observedMonoTime = START + 40_000_000 if change == 'new_limit': sm['slcDashboardObservation'].speedMps = 30.0 if change == 'earlier_handoff': sm.advance(START + 50_000_000) renewed = runtime.step(sm, cp, now_ns=START + 50_000_000).message.slcState self.assertEqual(renewed.decisionId, pending.slcState.decisionId) self.assertNotEqual(renewed.presentationId, pending.slcState.presentationId) now_ns = START + (100_000_000 if change == 'earlier_handoff' else 50_000_000) sm.advance(now_ns) output = runtime.step(sm, cp, now_ns=now_ns, request=action) if change == 'none': self.assertEqual(output.message.slcState.actionStatus, kind.value) self.assertFalse(output.message.slcState.hasPending) self.assertEqual(output.command is not None, kind == SlcActionKind.ACCEPT) else: self.assertTrue(output.message.slcState.hasPending) self.assertFalse(output.message.slcState.hasAccepted) self.assertIsNone(output.command) self.assertEqual(runtime.ledger.transactions, ()) def test_card_accept_produces_planner_receipt_and_bounded_command(self): sm, cp = replay_inputs() runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True, "SLCConfirmationHigher": True, "SLCPriority1": "Dashboard"}), session_id="ui-accept") pending = runtime.step(sm, cp, now_ns=START).message self.assertTrue(pending.slcState.hasPending) request = ui_request(pending, profile=Profile.LARGE, kind=SlcActionKind.ACCEPT) self.assertEqual(request.kind, SlcActionKind.ACCEPT) action = publish_request(pending.slcState, request, START) sm.advance(START + 50_000_000) result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action) self.assertEqual(result.message.slcState.actionStatus, "accept") self.assertFalse(result.message.slcState.hasPending) self.assertIsNotNone(result.command) self.assertGreater(result.command.slcCruiseCommand.targetMps, 60.0 / 3.6) def test_reject_or_timeout_then_use_limit_restores_rejected_presentation(self): for profile in Profile: for response in ('reject', 'timeout'): with self.subTest(profile=profile, response=response): sm, cp = replay_inputs() sm['slcDashboardObservation'].validUntilMonoTime = START + 20_000_000_000 runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True, "SLCPriority1": "Dashboard"}), session_id="ui-reject") pending = runtime.step(sm, cp, now_ns=START).message action = None now_ns = START + 10_000_000_000 if response == 'reject': reject = ui_request(pending, profile=profile, kind=SlcActionKind.REJECT) action = publish_request(pending.slcState, reject, START) now_ns = START + 50_000_000 sm.advance(now_ns) rejected = runtime.step(sm, cp, now_ns=now_ns, request=action) self.assertEqual(rejected.result.acceptance.basis, 'driver_reject' if action else 'timeout_reject') self.assertFalse(rejected.message.slcState.hasPending) self.assertIsNone(rejected.command) adopt = ui_request(rejected.message, profile=profile, kind=SlcActionKind.ADOPT) self.assertEqual(adopt.kind, SlcActionKind.ADOPT) restore = publish_request(rejected.message.slcState, adopt, now_ns) sm.advance(now_ns + 50_000_000) restored = runtime.step(sm, cp, now_ns=now_ns + 50_000_000, request=restore) self.assertEqual(restored.message.slcState.actionStatus, "adopt") self.assertIsNotNone(restored.command) self.assertAlmostEqual(restored.command.slcCruiseCommand.targetMps, 25.0) def test_same_accepted_limit_can_be_used_with_lower_selected_cruise(self): sm, cp = replay_inputs() runtime = accepted_runtime(sm, cp, parse({"SpeedLimitController": True, "SLCPriority1": "Dashboard"}), session_id="ui-readopt") shown = runtime.step(sm, cp, now_ns=START).message self.assertTrue(shown.slcState.hasAccepted) self.assertAlmostEqual(shown.slcState.acceptedSpeedLimit, shown.slcState.speedLimit) request = ui_request(shown, profile=Profile.LARGE, kind=SlcActionKind.ADOPT) self.assertEqual(request.kind, SlcActionKind.ADOPT) action = publish_request(shown.slcState, request, START) sm.advance(START + 50_000_000) result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action) self.assertEqual(result.message.slcState.actionStatus, "adopt") self.assertIsNotNone(result.command) def test_projected_use_limit_disappears_after_card_applies_rounded_set_speed(self): from openpilot.selfdrive.car.cruise import VCruiseHelper from openpilot.starpilot.system.starport.projection_onroad import ProjectionOnroad for metric, limit in ((False, 70 / 2.2369362921), (True, 90.4 / 3.6)): with self.subTest(metric=metric): sm, cp = replay_inputs() sm['slcDashboardObservation'].speedMps = limit runtime = accepted_runtime(sm, cp, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='aa-adopt') shown = runtime.step(sm, cp, now_ns=START).message state = OnroadState(True, False, 20.0, 60.0, speed_limit_from_message(shown.slcState), metric=metric, longitudinal_active=True, slc_system_long_available=True) control, = slc_controls(Profile.LARGE, state) left, top, right, bottom = control.bounds x, y = (left + right) / 2, (top + bottom) / 2 emitted = [] view = ProjectionOnroad.__new__(ProjectionOnroad) view.native = NS(ui_state=NS(started=True)) view._state = state view._slc_input = OnroadInput(emitted.append, Profile.LARGE) self.assertTrue(view._slc_touch('down', x, y)) self.assertTrue(view._slc_touch('up', x, y)) request, = emitted self.assertEqual(request.kind, SlcActionKind.ADOPT) action = publish_request(shown.slcState, request, START) sm.advance(START + 50_000_000) result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action) self.assertEqual(result.message.slcState.actionStatus, 'adopt') self.assertIsNotNone(result.command) cruise = VCruiseHelper(cp) cruise.apply_slc_target(result.command.slcCruiseCommand.targetMps, metric) applied = replace(state, cruise_kph=cruise.v_cruise_cluster_kph, speed_limit=speed_limit_from_message(result.message.slcState)) self.assertEqual(slc_controls(Profile.LARGE, applied), ()) # A later driver reduction must still offer the same limit again. self.assertEqual(slc_controls(Profile.LARGE, replace(applied, cruise_kph=60.0))[0].request.kind, SlcActionKind.ADOPT) def test_old_press_cannot_publish_after_new_runtime_presentation_or_staleness(self): sm, cp = replay_inputs() runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True, "SLCConfirmationHigher": True, "SLCPriority1": "Dashboard"}), session_id="ui-stale") pending = runtime.step(sm, cp, now_ns=START).message old_touch = ui_request(pending, profile=Profile.LARGE, kind=SlcActionKind.ACCEPT) publisher = Mock() stale = SlcActionDispatcher(publisher, lambda _: pending.slcState, lambda: START + 200_000_000) self.assertFalse(stale.dispatch(old_touch)) source = sm["slcDashboardObservation"] source.speedMps = 30.0 source.observedMonoTime = START + 40_000_000 source.episode = 2 sm.advance(START + 50_000_000) changed = runtime.step(sm, cp, now_ns=START + 50_000_000).message self.assertNotEqual(changed.slcState.presentationId, pending.slcState.presentationId) fresh = SlcActionDispatcher(publisher, lambda _: changed.slcState, lambda: START + 50_000_000) self.assertFalse(fresh.dispatch(old_touch)) publisher.send.assert_not_called() if __name__ == "__main__": unittest.main()