diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 17556482a2..fec6aab604 100644 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -803,12 +803,12 @@ class SelfdriveD: return CS - def held_axis_override_wait(self, CS, proof, now_ns): + def held_axis_override_wait(self, CS, proof, now_ns, *, fresh_successor=False): """Recognize an already applied override; never admit a new physical edge.""" if (proof is None or not (qualified_ioniq6(self.CP) or qualified_gm(self.CP)) or not self.sm.seen['carControl'] or not self.sm.valid['carControl'] or self.sm['carControl'].longActive or not 0 < int(self.sm.logMonoTime['carControl']) <= now_ns or - now_ns - int(self.sm.logMonoTime['carControl']) > INTENT_MAX_AGE_NS): + not fresh_successor and now_ns - int(self.sm.logMonoTime['carControl']) > INTENT_MAX_AGE_NS): return False, False previous = self.CS_prev samples = getattr(self, 'car_state_samples', ()) or ((CS, self.conditional_car_state_valid),) @@ -1273,6 +1273,17 @@ class SelfdriveD: source_ns = self.aol_car_state_log_ns intent = getattr(self, 'aol_last_intent', None) companion = getattr(self, 'aol_intent_companion', None) + fresh_successor = bool(proof[1] < source_ns <= now_ns and now_ns - source_ns <= INTENT_MAX_AGE_NS and + self.conditional_car_state_valid and CS.canValid and not CS.canTimeout and + intent is not None and intent.carStateLogMonoTime == source_ns and + intent.producerSessionId == proof[4] and intent.settingsQualified and + 0 < intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS and + companion is not None and companion.integrity_epoch == proof[5] and + decision.native_acknowledged and not self.aol_authority_lost and + getattr(self, 'aol_fault_reason', 'none') == 'none' and + not self.events.contains(ET.IMMEDIATE_DISABLE) and + (not decision.longitudinal_active or self.enabled and self.active)) user_disable = self.events.contains(ET.USER_DISABLE) if user_disable: from opendbc.car.hyundai.canfd_angle_aol import qualified as angle_qualified @@ -1287,21 +1298,17 @@ class SelfdriveD: if stock_lateral_role(self, CS, native, intent, allow_independent_cancel=True, allow_independent_brake=True, now_ns=now_ns): user_disable = False - if user_disable and self.held_axis_override_wait(CS, proof, now_ns)[0]: - # A moving held brake repeats pedalPressed while ordinary control is - # already disabled. Its existing lateral-only override is not a new edge. - user_disable = any(event.userDisable and event.name != EventName.pedalPressed for event in self.events.to_msg()) - if (proof[1] < source_ns <= now_ns and now_ns - source_ns <= INTENT_MAX_AGE_NS and - self.conditional_car_state_valid and CS.canValid and not CS.canTimeout and - intent is not None and intent.carStateLogMonoTime == source_ns and - intent.producerSessionId == proof[4] and intent.settingsQualified and - 0 < intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and - now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS and - companion is not None and companion.integrity_epoch == proof[5] and - decision.native_acknowledged and not self.aol_authority_lost and - getattr(self, 'aol_fault_reason', 'none') == 'none' and - not user_disable and not self.events.contains(ET.IMMEDIATE_DISABLE) and - (not decision.longitudinal_active or self.enabled and self.active)): + if user_disable and self.held_axis_override_wait(CS, proof, now_ns, fresh_successor=fresh_successor and qualified_gm(self.CP))[0]: + # A fresh same-owner source can establish its own LAT permission after + # an old output expires. Held-brake continuity does not renew that output; + # the current native proof below still has to grant the new decision. + stock_already_off = bool(fresh_successor and qualified_gm(self.CP) and self.CP.pcmCruise and + not self.CP.openpilotLongitudinalControl and not CS.cruiseState.enabled and + not self.CS_prev.cruiseState.enabled and all(valid and not sample.cruiseState.enabled + for sample, valid in getattr(self, 'car_state_samples', ()))) + user_disable = any(event.userDisable and event.name != EventName.pedalPressed and not + (stock_already_off and event.name == EventName.pcmDisable) for event in self.events.to_msg()) + if fresh_successor and not user_disable: native = current_native(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id) prior_native = getattr(self, 'aol_published_input_native', None) if (native is not None and prior_native is not None and native.pandaSerial == prior_native.pandaSerial and diff --git a/openpilot/starpilot/aol/tests/test_final_source_handoff.py b/openpilot/starpilot/aol/tests/test_final_source_handoff.py index 5f9691874f..255a3747d3 100644 --- a/openpilot/starpilot/aol/tests/test_final_source_handoff.py +++ b/openpilot/starpilot/aol/tests/test_final_source_handoff.py @@ -54,6 +54,10 @@ def test_final_handoff_full_owner(mode, condition): assert command.longActive == (mode == 'ordinary') deadline = original.validUntilMonoTime now = [BASE + 29_900_000] + def empty_control_receive(timeout): + assert timeout == 0 + actual_update_msgs(now[0] / 1e9, []) + cleanup.enter_context(mock.patch.object(controls.sm, 'update', side_effect=empty_control_receive)) queued, wires = deque(), deque() decisions, samples, states, deferred_proofs = [], [], [], [] original_receive = messaging.recv_one_or_none diff --git a/openpilot/starpilot/aol/tests/test_gm_held_brake_successor.py b/openpilot/starpilot/aol/tests/test_gm_held_brake_successor.py new file mode 100644 index 0000000000..d56d5bdaa7 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_gm_held_brake_successor.py @@ -0,0 +1,446 @@ +"""SDGM fresh held-brake successor across the recorded source/decision timing pair. + +Model/readiness and receive transport are bench fixtures; ECU I/O is skipped. +Neither ordinary controls_allowed nor an active steering command is seeded. +""" + +from contextlib import ExitStack +from unittest.mock import patch + +import pytest +from opendbc.can import CANPacker +from opendbc.car import Bus, structs +from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params +from opendbc.car.gm.values import DBC +from opendbc.safety.tests.libsafety import libsafety_py as native +from openpilot.cereal import messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.starpilot.aol.intent import read_settings +from openpilot.starpilot.aol.tests import test_gm as gm +from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture +from openpilot.starpilot.aol.tests.test_gm_startup_main import parsed_packets +from openpilot.starpilot.aol.wire import PandaSlot, SafetyState, encode_safety +from openpilot.starpilot.lateral.tests.test_lane_runtime import feed + + +def sdgm_packets(cp, packer, now, tick, speed, main, gesture): + speed = (5.858360767 if tick < 390 else 5.836455345) if speed > 0 else 0.0 + frames = parsed_packets(cp, packer, now, tick, speed, main) + engine = packer.make_can_msg( + "ECMEngineStatus", + 0, + {"CruiseMainOn": int(main), "BrakePressed": int(tick > 300)}, + ) + brake = packer.make_can_msg("ECMAcceleratorPos", 0, {"BrakePedalPos": 8 if tick > 300 else 0}) + frames = [f for f in frames if f[0] not in (engine[0], brake[0])] + return frames + [engine, brake] + + +@pytest.mark.parametrize("alpha", [False, True]) +@pytest.mark.parametrize("c9", [False, True]) +def test_actual_sdgm_held_brake_fresh_successor_after_expired_output(c9, alpha): + exercise_sdgm(c9=c9, alpha=alpha) + + +def exercise_sdgm(c9, alpha, condition="healthy"): + main_action = 0 + gesture = "held_brake" + cp = sdgm_params(alpha=alpha, brake_c9=c9) + packet_factory = sdgm_packets + below_engage = False + case = fixture.IoniqAvailabilityTests(methodName="runTest") + with OpenpilotPrefix(), ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + sd, controls, step = case._authority_callers(False, cp=cp, genuine_events=True) + sd.params.put("MainCruiseButtonControl", main_action, block=True) + sd.aol_settings = read_settings(sd.params) + # The initial real Controls result is disabled because no authority wire exists. + # This boot-neutral fixture establishes no Panda grant or active output. + feed(controls, 10_000_000_000, 0, active=False, enabled=False) + boot, boot_log = controls.state_control() + assert not boot.latActive and not boot.longActive + controls.publish(boot, boot_log) + card = gm.TestGmAol.card(cp, sd.params) + cleanup.callback(card.vehicle_startup.close) + card.ci_initialized = True + card.car_params_published = True + card.sm.frame = 1 + assert card.CP.to_dict() == sd.CP.to_dict() == controls.CP.to_dict() + original_pm = card.pm + captured = {} + intent_queue = [] + companion_queue = [] + original_optional = messaging.recv_one_or_none + + def deliver(sock): + if sd.steering_companion is not None and sock is sd.steering_companion.sock: + return companion_queue.pop(0) if companion_queue else None + if sd.aol_intent_companion is not None and sock is sd.aol_intent_companion.sock: + return intent_queue.pop(0) if intent_queue else None + return original_optional(sock) + + cleanup.enter_context( + patch( + "openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one_or_none", + side_effect=deliver, + ) + ) + + class Tee: + def send(self, service, message): + original_pm.send(service, message) + event = messaging.log_from_bytes(message) if isinstance(message, bytes) else message.as_reader() + captured[service] = event.as_builder() + if service == "aolIntentWire": + intent_queue.append(event) + if service == "starpilotCarState": + companion_queue.append(event) + + card.pm = Tee() + sd_receive = sd.sm.update_msgs + last_sd_control_stamp = 0 + + def receive_sd(stamp, rows): + nonlocal last_sd_control_stamp + # Deliver the original last published Controls envelope without renewing its source. + published = controls.pm.messages.get("carControl") + rows = [r for r in rows if r.which() != "carControl"] + if published is not None and published.logMonoTime != last_sd_control_stamp: + rows.append(published) + last_sd_control_stamp = int(published.logMonoTime) + return sd_receive(stamp, rows) + + cleanup.enter_context(patch.object(sd.sm, "update_msgs", side_effect=receive_sd)) + safety = native.libsafety + safety.init_tests() + safety.set_alternative_experience(cp.alternativeExperience) + assert safety.set_safety_hooks(4, cp.safetyConfigs[0].safetyParam) == 0 + # Establish actual neutral producer cadence before Card publishes carOutput. + card_receive = card.sm.update_msgs + card_receive(10.0, [controls.pm.messages["carControl"]]) + boot_rows = [] + with patch.object( + controls.sm, + "update_msgs", + side_effect=lambda stamp, rows: boot_rows.extend(rows), + ): + feed(controls, 10_010_000_000, 1, active=False, enabled=False) + controls.sm.update_msgs(10.01, boot_rows) + clocked = messaging.new_message + with patch( + "openpilot.selfdrive.controls.controlsd.messaging.new_message", + side_effect=lambda *args, **kwargs: clocked(*args, **dict(kwargs, logMonoTime=10_010_000_000)), + ): + neutral, neutral_log = controls.state_control() + assert not neutral.latActive and not neutral.longActive + controls.publish(neutral, neutral_log) + card_receive(10.01, [controls.pm.messages["carControl"]]) + last_control_stamp = int(controls.pm.messages["carControl"].logMonoTime) + delivered = {} + + if card.volt_cc_selected: + # Establish the real clock floor before any fresh physical/control input. + with ( + patch( + "openpilot.selfdrive.car.card.time.monotonic_ns", + return_value=10_010_000_000, + ), + patch( + "openpilot.selfdrive.car.card.time.clock_gettime_ns", + return_value=10_010_000_000, + ), + ): + assert not card.volt_cc_control_current() + + def wire_batch(now): + mask = safety.aol_get_permission_mask() + request = safety.aol_get_request_mask() + invalid = not safety.safety_config_valid() + relay = safety.get_relay_malfunction() + p = messaging.new_message("pandaStates", 1, valid=True, logMonoTime=now) + slot = p.pandaStates[0] + slot.safetyModel = 4 + slot.safetyParam = cp.safetyConfigs[0].safetyParam + slot.alternativeExperience = cp.alternativeExperience + slot.controlsAllowed = safety.get_controls_allowed() + slot.safetyRxChecksInvalid = invalid + slot.faults = ["relayMalfunction"] if relay else [] + w = messaging.new_message("aolSafetyWire", 0, valid=True, logMonoTime=now) + w.aolSafetyWire = encode_safety( + SafetyState( + 1, + True, + now, + now + 200_000_000, + 4, + cp.safetyConfigs[0].safetyParam, + bool(mask & 1), + bool(mask & 2), + bool(request & 1), + bool(request & 2), + "authority-panda", + sd.aol_session_id, + now, + ( + PandaSlot( + 0, + "authority-panda", + 4, + cp.safetyConfigs[0].safetyParam, + cp.alternativeExperience, + safety.get_controls_allowed(), + invalid, + False, + 1 if relay else 0, + ), + ), + ) + ) + return p.as_reader(), w.as_reader() + + def receive_card(now, *, control=True): + nonlocal last_control_stamp + rows = list(wire_batch(now)) + if card.volt_cc_selected: + device = messaging.new_message("deviceState", valid=True, logMonoTime=now) + device.deviceState.started, device.deviceState.startedMonoTime = ( + True, + 9_000_000_000, + ) + rows.append(device.as_reader()) + rows += [sd.pm.messages[k] for k in ("aolAxisState", "selfdriveState", "onroadEvents") if k in sd.pm.messages] + published = controls.pm.messages["carControl"] + if control and published.logMonoTime != last_control_stamp: + rows.append(published) + last_control_stamp = int(published.logMonoTime) + unique = [] + for event in rows: + name = event.which() + key = (int(event.logMonoTime), event.as_builder().to_bytes()) + if delivered.get(name) != key: + unique.append(event) + delivered[name] = key + card_receive(now / 1e9, unique) + + packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) + + def physical(tick, stamp, driving): + speed = max(4.0, cp.minSteerSpeed + 2.0) + if below_engage: + assert speed < cp.minEnableSpeed + else: + speed = max(speed, cp.minEnableSpeed + 2.0) + packets = packet_factory(cp, packer, stamp, tick - 100, speed if driving else 0.0, True, gesture) + packets = [f for f in packets if f[0] not in (0x1F5, 0x12A, 0x184, 0x17D)] + if cp.pcmCruise and cp.networkLocation == structs.CarParams.NetworkLocation.fwdCamera: + address = packer.make_can_msg("ASCMActiveCruiseControlStatus", 2, {})[0] + packets = [f for f in packets if not (f[0] == address and f[2] == 2)] + # Factory camera reports adaptive standby, not non-adaptive cruise mode. + packets.append(packer.make_can_msg("ASCMActiveCruiseControlStatus", 2, {"ACCCruiseState": 2})) + packets += [ + packer.make_can_msg("ESPStatus", 0, {"TractionControlOn": 1}), + packer.make_can_msg("ECMPRDNL2", 0, {"PRNDL2": 4 if driving else 1}), + packer.make_can_msg("BCMDoorBeltStatus", 0, {"LeftSeatBelt": int(driving)}), + packer.make_can_msg("PSCMStatus", 0, {"LKATorqueDeliveredStatus": int(driving)}), + ] + return packets + + for warm in range(100): + stamp = 9_000_000_000 + warm * 10_000_000 + safety.set_timer(stamp // 1000) + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(0) + packets = physical(warm, stamp, False) + for a, data, bus in packets: + safety.safety_fwd_hook(bus, a) + assert safety.safety_rx_hook(native.make_CANPacket(a, bus, data)) + card.CI.update([(stamp, packets)]) + safety.safety_tick() + assert safety.safety_config_valid() and safety.aol_get_permission_mask() == 0 + tx = 0 + active = 0 + checkpoints = {} + old_axis = None + for tick in range(1, 391): + now = 10_020_000_000 + (tick - 1) * 10_000_000 + if tick == 390: + now += 20_271_875 + decision_ns = now + (6_268_334 if tick == 389 else 11_981_615 if tick == 390 else 0) + driving = tick > 300 + safety.set_timer(now // 1000) + safety.set_aol_test_heartbeat(True) + packets = physical(tick + 100, now, driving) + for a, data, bus in packets: + safety.safety_fwd_hook(bus, a) + assert safety.safety_rx_hook(native.make_CANPacket(a, bus, data)) + raw = messaging.new_message('can', len(packets), valid=True, logMonoTime=now) + for dst, (address, data, bus) in zip(raw.can, packets, strict=True): + dst.address, dst.dat, dst.src = address, data, bus + with patch('openpilot.selfdrive.car.card.messaging.drain_sock_raw', return_value=[raw.to_bytes()]), \ + patch.object(card.sm, 'update', side_effect=lambda timeout, _now=now: receive_card(_now)), \ + patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=now), \ + patch('openpilot.selfdrive.car.card.time.clock_gettime_ns', return_value=now): + cs, _ = card.state_update(defer_radar=True) + safety.safety_tick() + assert cs.canValid and not cs.canTimeout + assert card._aol_can_observation[0] is cs and card._aol_can_observation[1:3] == (now, now) + allowed = card.aol_card_intent.output(cs)[0] + request = int(allowed) | (2 if sd.aol_axis_decision.desired_longitudinal else 0) + safety.set_timer(decision_ns // 1000) + safety.aol_set_host_request(request) + mask = safety.aol_get_permission_mask() + message = messaging.new_message + with patch( + "openpilot.selfdrive.car.card.messaging.new_message", + side_effect=lambda *args, _clock=message, _now=now, **kwargs: _clock(*args, **dict(kwargs, logMonoTime=_now)), + ): + card.state_publish(cs, None) + negative = tick == 390 and condition != "healthy" + if negative: + # Typed receiver perturbations start after a complete actual CAN/native + # baseline; they cannot manufacture native permission or renew old output. + from dataclasses import replace + from openpilot.starpilot.aol.wire import decode_intent, encode_intent + current = captured["carState"].carState + if condition == "new_brake": + sd.CS_prev = sd.CS_prev.as_builder() + sd.CS_prev.brakePressed = False + elif condition == "new_paddle": + current.regenBraking = True + elif condition == "new_gas": + current.gasPressed = True + elif condition == "cancel": + current.buttonEvents = [structs.CarState.ButtonEvent(type="cancel", pressed=True)] + elif condition == "invalid_current": + current.canValid, current.canTimeout = False, True + elif condition == "permanent_eps": + current.steerFaultPermanent = True + elif condition == "main_off": + current.cruiseState.available = False + elif condition == "gear_unknown": + current.gearShifter = "unknown" + elif condition == "stock_disable_edge": + sd.CS_prev = sd.CS_prev.as_builder() + sd.CS_prev.cruiseState.enabled = True + if condition in ("future_current", "old_current"): + source = decision_ns + 1 if condition == "future_current" else old_axis.sourceCarStateMonoTime + captured["carState"].logMonoTime = source + wire = captured["aolIntentWire"] + wire.aolIntentWire = encode_intent(replace(decode_intent(wire.aolIntentWire), carStateLogMonoTime=source)) + changes = {"producer": {"producerSessionId": "other-card"}, "settings": {"settingsQualified": False}, + "pause_lateral": {"pauseLateral": True}} + if condition in changes: + wire = captured["aolIntentWire"] + wire.aolIntentWire = encode_intent(replace(decode_intent(wire.aolIntentWire), **changes[condition])) + if condition == "epoch": + sd.aol_intent_companion.integrity_epoch += 1 + axis, _, cc = step( + tick, + state_stamp=now, + offset_ns=decision_ns - 10_000_000_000 - tick * 10_000_000, + car_state_message=captured["carState"], + intent_message=captured["aolIntentWire"], + car_output_message=captured["carOutput"], + car_output_valid=captured["carOutput"].valid, + allowed=bool(mask & 1) and not (negative and condition == "native_denied"), + native_valid=not (negative and condition == "native_invalid"), + panda_fault=negative and condition == "rx_fault", + relay_fault=negative and condition == "relay_fault", + long_allowed=bool(mask & 2), + native_request_lat=bool(request & 1), + native_request_long=bool(request & 2), + panda_controls_allowed=safety.get_controls_allowed(), + authority_message=captured.get("starpilotCarState"), + ) + if negative: + assert old_axis.validUntilMonoTime < decision_ns + assert not axis.lateralActive and not cc.latActive, (condition, axis.to_dict(), sd.events.names) + assert not axis.longitudinalActive and not cc.longActive + return + if tick == 389: + old_axis = axis + receive_card(decision_ns, control=False) + if tick == 390: + assert old_axis.validUntilMonoTime < decision_ns + old_deadline = old_axis.validUntilMonoTime + receive_card(decision_ns) + assert old_axis.validUntilMonoTime == old_deadline + assert axis.validUntilMonoTime == decision_ns + 30_000_000 + assert not cc.enabled and not cc.longActive and not safety.get_controls_allowed() + if tick == 390: + assert not sd.aol_authority_lost and str(axis.faultReason) == "none" + assert axis.lateralActive and cc.latActive and not axis.longitudinalActive + assert axis.sourceCarStateMonoTime == now and axis.observedMonoTime == decision_ns + if tick in (360, 420, 480, 520): + checkpoints[tick] = ( + bool(cc.enabled), + bool(cc.longActive), + bool(cc.latActive), + card.v_cruise_helper.v_cruise_kph, + bool(cs.brakePressed), + bool(cs.regenBraking), + ) + if not driving: + assert str(cs.gearShifter) == "park" and cs.seatbeltUnlatched and cs.standstill + assert not axis.lateralActive and not cc.latActive and safety.aol_get_request_mask() == 0 and mask == 0 + assert str(axis.faultReason) != "critical", ( + tick, + axis.to_dict(), + sd.events.names, + ) + failed = card.aol_disarming_fault(cs, int(card.sm.logMonoTime["onroadEvents"]), decision_ns) + if failed: + print( + "SDGM_FAILURE", + tick, + gesture, + axis.to_dict(), + [e.to_dict() for e in sd.events.to_msg()], + card.aol_transport_feedback.__dict__, + checkpoints, + ) + assert not failed + assert card.aol_card_intent.allowed_latch and not card.aol_card_intent._main_cycle_required + command = card.sm["carControl"] + captured.pop("sendcan", None) + with ( + patch( + "openpilot.selfdrive.car.card.time.monotonic_ns", + return_value=decision_ns + 2, + ), + patch( + "openpilot.selfdrive.car.card.time.monotonic", + return_value=(decision_ns + 2) / 1e9, + ), + patch( + "openpilot.selfdrive.car.card.time.clock_gettime_ns", + return_value=decision_ns + 2, + ), + ): + card.controls_update(cs, command) + emitted = captured["sendcan"] + if not emitted.valid: + # The first command is exactly at the real source floor; it cannot emit. + assert tick == 1 and not emitted.sendcan and not cc.enabled and not cc.latActive and not cc.longActive + card.CS_prev = cs + continue + assert emitted.valid + for frame in emitted.sendcan: + assert safety.safety_tx_hook(native.make_CANPacket(frame.address, frame.src, frame.dat)) + tx += 1 + if frame.address == 0x180: + if not driving: + assert not frame.dat[0] & 8 + else: + active += bool(frame.dat[0] & 8) + card.CS_prev = cs + assert active > 0 and tx > 0 and card.aol_card_intent.allowed_latch + assert not sd.aol_authority_lost + + +@pytest.mark.parametrize("c9", [False, True]) +@pytest.mark.parametrize("condition", ["new_brake", "new_paddle", "cancel", "invalid_current", "future_current", + "old_current", "new_gas", "producer", "epoch", "settings", "pause_lateral", "native_denied", "native_invalid", "rx_fault", + "relay_fault", "permanent_eps", "main_off", "gear_unknown", "stock_disable_edge"]) +def test_fresh_held_successor_rejects_changed_identity_health_or_physical_input(c9, condition): + exercise_sdgm(c9=c9, alpha=condition != "stock_disable_edge", condition=condition) diff --git a/openpilot/starpilot/aol/tests/test_gm_source_deadline.py b/openpilot/starpilot/aol/tests/test_gm_source_deadline.py index 789ce02a60..5ec2f41807 100644 --- a/openpilot/starpilot/aol/tests/test_gm_source_deadline.py +++ b/openpilot/starpilot/aol/tests/test_gm_source_deadline.py @@ -1,16 +1,45 @@ """Volt route c0508: source cutoff must not replace an unexpired axis proof.""" +from collections import deque from contextlib import ExitStack from unittest.mock import patch import pytest from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params +from openpilot.cereal import messaging from openpilot.common.prefix import OpenpilotPrefix from openpilot.starpilot.aol.intent import read_settings from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture from openpilot.starpilot.aol.tests.test_input_deadline_continuity import BASE, intent +def bind_steering_receipts(sd, step, cleanup): + """Deliver the same declared producer receipt through its actual socket path.""" + if sd.steering_companion is None: + return step + pending = deque() + original = messaging.recv_one_or_none + + def receive(sock): + if sock is sd.steering_companion.sock: + return pending.popleft() if pending else None + return original(sock) + + cleanup.enter_context(patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one_or_none', side_effect=receive)) + + def observed_step(tick, **kwargs): + if not kwargs.get('timeout', False): + stamp = kwargs.get('state_stamp', 10_000_000_000 + tick * 10_000_000 + kwargs.get('offset_ns', 0)) + receipt = messaging.new_message('starpilotCarState', valid=True, logMonoTime=stamp) + receipt.starpilotCarState.sourceCarStateMonoTime = stamp + receipt.starpilotCarState.lateralAuthorityUnavailable = kwargs.get('authority_unavailable', False) + kwargs['authority_message'] = receipt + pending.append(receipt.as_reader()) + return step(tick, **kwargs) + + return observed_step + + def profiles(): from opendbc.car.gm.tests.test_volt_camera_control import camera_params from opendbc.car.gm.tests.test_volt_camera_removed import removed_params @@ -38,6 +67,7 @@ def test_volt_cutoff_keeps_only_original_lease_then_consumes_next_source(mode, s cleanup.callback(case.doCleanups) full = mode == 'ordinary' sd, controls, step = case._authority_callers(full, cp=cp.as_reader().as_builder(), genuine_events=True) + step = bind_steering_receipts(sd, step, cleanup) sd.aol_settings = read_settings(sd.params) cs = fixture.car_state(brake=mode == 'brake') cs.regenBraking = mode == 'regen' @@ -46,6 +76,13 @@ def test_volt_cutoff_keeps_only_original_lease_then_consumes_next_source(mode, s assert old.lateralActive and command.latActive and bool(command.longActive) == full deadline = old.validUntilMonoTime clock = [BASE + 29_900_000] + actual_controls_receive = controls.sm.update_msgs + + def empty_control_receive(timeout): + assert timeout == 0 + actual_controls_receive(clock[0] / 1e9, []) + + cleanup.enter_context(patch.object(controls.sm, 'update', side_effect=empty_control_receive)) original = sd.update_process_health def finish_preamble(): @@ -57,6 +94,15 @@ def test_volt_cutoff_keeps_only_original_lease_then_consumes_next_source(mode, s patch.object(sd, 'update_process_health', side_effect=finish_preamble): held, _, _ = step(5, timeout=True, state_stamp=BASE, offset_ns=-100_000, intent_message=intent(BASE, 3), observed_state=cs, native_request_long=full) + from opendbc.car.gm.steering_authority import monitored_profile + if monitored_profile(cp): + # This monitored profile also needs its original steering-health receipt. + # At this point that source is older than30ms. Its missing successor cannot + # inherit health from an unexpired axis envelope or a fresh Panda grant. + assert not sd.aol_axis_publication_deferred + assert not held.lateralActive and not held.longitudinalActive + assert sd.steering_authority_unavailable and sd.aol_authority_lost + return assert sd.aol_axis_publication_deferred assert held.to_dict() == old.to_dict() and held.validUntilMonoTime == deadline assert not sd.aol_authority_lost @@ -90,6 +136,7 @@ def test_volt_deferral_cannot_hide_driver_edges_or_failed_proof(bad): with OpenpilotPrefix(), ExitStack() as cleanup: cleanup.callback(case.doCleanups) sd, _, step = case._authority_callers(False, cp=sdgm_params(alpha=True), genuine_events=True) + step = bind_steering_receipts(sd, step, cleanup) sd.aol_settings = read_settings(sd.params) old, _, command = step(2, state_stamp=BASE, offset_ns=4_530_833, intent_message=intent(BASE, 3), native_request_long=False) @@ -135,6 +182,7 @@ def test_ascm_separate_steering_companion_expiry_still_withdraws_outputs(cp): with OpenpilotPrefix(), ExitStack() as cleanup: cleanup.callback(case.doCleanups) sd, _, step = case._authority_callers(True, cp=cp.as_reader().as_builder(), genuine_events=True) + step = bind_steering_receipts(sd, step, cleanup) sd.aol_settings = read_settings(sd.params) from collections import deque queue = deque() diff --git a/prebuilt.json b/prebuilt.json index 997f7b8154..7982f72ec0 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "3431c43b06385ea8e9a91a7820b952792f1a1c27069c5ac04fc7f17227490b4c", "schema_version": 1} +{"protected_inventory_sha256": "8b4e37f4b6b70b41edab93d19b3690a747cd7eeeb7db5c681f24252e26f13818", "schema_version": 1}