From d6b8ade37f485c8d2185eccf8ee526ecad421b49 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:34:54 -0500 Subject: [PATCH] Defer torque EV ECU disable until inactive controls are ready --- .../tests/test_torque_ev_deferred_takeover.py | 211 ++++++++++++++++++ .../opendbc/car/hyundai/torque_ev_startup.py | 48 +++- openpilot/selfdrive/car/card.py | 17 +- prebuilt.json | 2 +- tools/ci/run_vehicle_tests.py | 2 + 5 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py b/opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py new file mode 100644 index 0000000000..5c75455ae0 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py @@ -0,0 +1,211 @@ +"""Keep factory SCC until an inactive producer is current; never bypass native RX.""" +import os +from unittest.mock import patch + +import pytest + +from opendbc.car import Bus, structs +from opendbc.car.hyundai.ecu_startup import Outcome +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.radar_interface import RadarInterface +from opendbc.car.hyundai.tests.test_torque_ev_startup import Keeper, owner, physical_frames, stock as stock +from opendbc.can.packer import CANPacker +from opendbc.safety.tests.libsafety import libsafety_py +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.car.card import Car +from openpilot.starpilot.vehicle_startup import VehicleStartupOwner + + +@pytest.mark.parametrize("scenario", ( + "healthy_suppressed", "healthy_positive", "stale_control", "active_control", "missing_source", + "native_mode", "cruise_fault", "stock_engaged", "disable_negative", "disable_malformed", "restore_failure", "publisher_failure")) +def test_actual_card_defers_torque_ev_until_inactive_producer_and_then_native_rx(stock, scenario): + native = libsafety_py.libsafety + assert native.set_safety_hooks(int(structs.CarParams.SafetyModel.elm327), 1) == 0 + native.init_tests() + now = [100_000_000_000] + requests, emitted = [], [] + muted = [False] + + def send(frames): + for address, data, bus in frames: + assert native.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data)) + emitted.extend(frames) + if scenario == "publisher_failure" and any(data[:4] == b"\x03\x28\x83\x01" for _, data, _ in frames): + muted[0] = True + raise OSError("diagnostic publisher failed after possible ECU delivery") + + def query(sender, receiver, bus, addresses, commands, responses): + command = commands[0] + requests.append((now[0], command)) + + class Query: + def get_data(self, timeout): + sender([(0x730, bytes([len(command)]) + command + bytes(7 - len(command)), bus)]) + if command == b"\x28\x83\x01": + muted[0] = True + if scenario in ("disable_negative", "restore_failure"): + return {(0x730, None): b"\x7f\x28\x22"} + if scenario == "disable_malformed": + return {(0x730, None): b"\x68"} + return {(0x730, None): b"\x68\x03"} if scenario == "healthy_positive" else {} + if command == b"\x28\x00\x01": + if scenario == "restore_failure": + return {} + muted[0] = False + return {(0x730, None): b""} + return Query() + + def sleep(seconds): + now[0] += round(seconds * 1e9) + + def update_sm(host, command, stale=False): + message = messaging.new_message("carControl", valid=True, logMonoTime=now[0] - (151_000_000 if stale else 0)) + message.carControl = command + panda = messaging.new_message("pandaStates", 1, valid=True, logMonoTime=now[0]) + state = panda.pandaStates[0] + state.safetyModel, state.safetyParam = native.get_current_safety_mode(), native.get_current_safety_param() + state.alternativeExperience = host.CP.alternativeExperience + state.ignitionLine = True + state.safetyRxChecksInvalid = native.safety_test_toyota_rx_summary_invalid() + host.sm.update_msgs(now[0] / 1e9, [message.as_reader(), panda.as_reader()]) + + with OpenpilotPrefix(), patch.dict(os.environ, {"SIMULATION": "0", "AOL_REPLAY_RUNTIME": "1"}), \ + patch("time.clock_gettime_ns", side_effect=lambda clock: now[0]), \ + patch("time.monotonic_ns", side_effect=lambda: now[0]), \ + patch("time.monotonic", side_effect=lambda: now[0] / 1e9), \ + patch("time.sleep", side_effect=sleep): + saved = Params() + for key in ("OpenpilotEnabledToggle", "AlwaysOnLateral", "AlphaLongitudinalEnabled"): + saved.put_bool(key, True, block=True) + holder = VehicleStartupOwner() + prepared = holder.prepare(stock, CarInterface, (list, send), requested=True, + admission=lambda: True, + defer_until_controls=True) + startup = holder.owner + assert startup is not None + startup.clock, startup.keeper_factory, startup._query = lambda: now[0] / 1e9, Keeper, query + try: + assert startup.outcome is Outcome.PENDING and not requests and not emitted + assert not startup.before_control(configured=True, sources_current=True, control_current=True) + card = Car(CI=CarInterface(prepared), RI=RadarInterface(prepared), startup_owner=startup) + startup.admission = card.startup_diagnostic_admission + card.params.put_bool("IsOffroad", False, block=True) + original_cp = card.CP.to_dict() + packer = CANPacker(card.CI.can_parsers[Bus.pt].dbc.name) + # Explicit synthetic delayed producer: stock has not been told to stop. + for tick in range(1, 701): + now[0] += 10_000_000 + state = card.CI.update([(now[0], physical_frames(card.CP, packer, tick))]) + assert state.canValid and not state.canTimeout and not state.accFaulted + assert not requests and not muted[0] and startup.keeper is None + command = structs.CarControl(enabled=scenario == "active_control", latActive=scenario == "active_control").as_reader() + if scenario == "missing_source": + parser = card.CI.can_parsers[Bus.pt] + parser.message_states[parser.dbc.name_to_msg["TCS"].address].timestamps.clear() + if scenario == "cruise_fault": + state.accFaulted = True + if scenario == "stock_engaged": + state.cruiseState.enabled = True + config = card.CP.safetyConfigs[-1] + if scenario == "native_mode": + native.set_alternative_experience(card.CP.alternativeExperience) + assert native.set_safety_hooks(int(config.safetyModel.raw), int(config.safetyParam)) == 0 + update_sm(card, command, stale=scenario == "stale_control") + if scenario in ("disable_negative", "disable_malformed", "restore_failure", "publisher_failure"): + with pytest.raises(RuntimeError, match="pending"): + card.controls_update(state, command, initialize_only=True) + assert startup.outcome is Outcome.ABORT_UNCERTAIN and not card.ci_initialized + assert card.CP.to_dict() == original_cp and not startup.handed_off + assert muted[0] == (scenario == "restore_failure") + assert startup.restored == (scenario != "restore_failure") + return + card.controls_update(state, command, initialize_only=True) + if not scenario.startswith("healthy"): + assert not requests and not muted[0] and not card.ci_initialized + assert startup.outcome is Outcome.PENDING and card.CP.to_dict() == original_cp + return + assert [command for _, command in requests] == [b"\x10\x03", b"\x28\x83\x01"] + assert muted[0] and card.ci_initialized and startup.outcome is Outcome.SENT_UNCONFIRMED + assert startup.keeper.started and not startup.handed_off + # No SCC while native is diagnostic, mismatched, or awaiting RX health. + emitted.clear() + card.controls_update(state, command, initialize_only=True) + assert not emitted and not startup.handed_off + native.set_alternative_experience(card.CP.alternativeExperience) + assert native.set_safety_hooks(int(config.safetyModel.raw), int(config.safetyParam)) == 0 + native.init_tests() + update_sm(card, command) + card.controls_update(state, command, initialize_only=True) + assert not emitted and not startup.handed_off + for tick in range(701, 811): + now[0] += 10_000_000 + frames = physical_frames(card.CP, packer, tick) + state = card.CI.update([(now[0], frames)]) + native.set_timer(tick * 10_000) + for address, data, bus in frames: + native.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data)) + if tick % 100 == 0: + native.safety_tick() + assert native.safety_config_valid() + update_sm(card, command) + captured = [] + + def publish(frames, valid=True): + if valid: + send(frames) + card.vehicle_startup.sent(frames, valid=valid) + captured.extend(frames) + + with patch.object(card, "publish_sendcan", side_effect=publish): + card.controls_update(state, command, initialize_only=True) + assert any(address == 0x1A0 and bus == 1 for address, _, bus in captured) + assert startup.handed_off and not startup.keeper.started + assert not native.get_controls_allowed() and native.aol_get_permission_mask() == 0 + assert card.CP.to_dict() == original_cp + finally: + holder.close() + + +@pytest.mark.parametrize("delay", (0, 6, 30, 120)) +def test_pending_prepare_and_close_never_send_diagnostics_while_producer_is_absent(stock, delay): + startup, sent = owner(stock) + startup.prepare_when_controls_ready(admission=lambda: True) + assert startup.outcome is Outcome.PENDING + with patch("time.monotonic", return_value=100.0 + delay): + startup.close() + assert not sent and not startup.session_attempted and not startup.disable_attempted + assert not startup.restored and startup.closed + + +def test_missing_control_context_never_admits_output(stock): + startup, sent = owner(stock) + try: + startup.prepare_when_controls_ready(admission=lambda: True) + assert not startup.before_control() + startup.outcome = Outcome.SENT_UNCONFIRMED + assert not startup.before_control() + assert not sent + finally: + startup.close() + + +@pytest.fixture(scope="module") +def release_native(): + return libsafety_py.ffi.dlopen(libsafety_py._build_libsafety(release=True)) + + +def test_release_native_refuses_alpha_torque_long(stock, release_native): + from opendbc.car.hyundai.torque_ev_startup import candidate + assert candidate(stock, requested=True, is_release=True) is None + cp = candidate(stock, requested=True, is_release=False) + assert cp is not None + release_native.set_alternative_experience(32) + assert release_native.set_safety_hooks(int(structs.CarParams.SafetyModel.hyundaiCanfd), int(cp.safetyConfigs[0].safetyParam) | 0x0800) == 0 + release_native.init_tests() + packer = CANPacker("hyundai_canfd_generated") + active = packer.make_can_msg("SCC_CONTROL", 1, {"ACCMode": 1, "aReqRaw": 1.0, "aReqValue": 1.0}) + assert not release_native.safety_tx_hook(libsafety_py.make_CANPacket(active[0], active[2], active[1])) + assert not release_native.get_controls_allowed() and release_native.aol_get_permission_mask() == 0 diff --git a/opendbc_repo/opendbc/car/hyundai/torque_ev_startup.py b/opendbc_repo/opendbc/car/hyundai/torque_ev_startup.py index 3a616577ea..4d708c6c4f 100644 --- a/opendbc_repo/opendbc/car/hyundai/torque_ev_startup.py +++ b/opendbc_repo/opendbc/car/hyundai/torque_ev_startup.py @@ -77,6 +77,46 @@ class TorqueEVStartup(HyundaiECUStartup): self.keeper = None self.handed_off = False + def prepare_when_controls_ready(self, *, admission): + """Leave factory SCC intact while the host's inactive producer starts.""" + if self.outcome is not None or self.closed: + raise RuntimeError("Torque EV startup preparation is single-use") + if admission() is not True: + return self.prepare(admission=lambda: False) + self.admission = admission + self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME) + self.outcome = Outcome.PENDING + self.prepared_cp = deepcopy(self.cp.to_dict()) + return self.cp + + def commit_pending(self, ci, *, sources_current, control_current): + if (self.outcome is not Outcome.PENDING or self.closed or not self.published or + ci is not self.ci or not self.prepared_for(ci.CP) or not sources_current or + not control_current or self.admission is None or self.admission() is not True): + raise RuntimeError("Torque EV takeover requires current diagnostic, source and inactive control admission") + try: + success = self._disable(self.callbacks[0], self._send, bus=self.bus, addr=self.address, + com_cont_req=self.communication_control_request()) + if not success or not self.disable_attempted: + raise RuntimeError("Torque EV pending ECU disable was not sent successfully") + self.outcome = Outcome.SENT_UNCONFIRMED + self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME) + self.keeper = self.keeper_factory( + lambda: self.callbacks[1]([make_tester_present_msg(self.address, self.bus, suppress_response=True)]), clock=self.clock) + self.keeper.start() + except BaseException: + if self.keeper is not None: + self.keeper.stop() + try: + restored = not (self.session_attempted or self.disable_attempted) or self._restore() + except Exception: + restored = False + # CP is published: restoration never silently changes the LONG profile. + self.outcome = Outcome.ABORT_UNCERTAIN + if not restored: + raise RuntimeError("Torque EV pending takeover failed; stock restoration unverified") from None + raise + def _disable(self, can_recv, can_send, **kwargs): # Existing generic helper returns after send without inspecting negatives. # Preserve original explicit rejection vs expected suppressed silence. @@ -128,8 +168,10 @@ class TorqueEVStartup(HyundaiECUStartup): return super().finalize_aol_configuration(ci) from opendbc.car.hyundai.canfd_stock_aol import qualified_long - if self.outcome is not Outcome.SENT_UNCONFIRMED or not qualified_long(ci.CP, marked_only=True): + if self.outcome not in (Outcome.PENDING, Outcome.SENT_UNCONFIRMED) or not qualified_long(ci.CP, marked_only=True): raise RuntimeError("Torque EV startup does not admit this AOL configuration") + if self.prepared_cp is None: + raise RuntimeError("Torque EV startup requires prepared CarParams") expected = deepcopy(self.prepared_cp) configs = expected.get("safetyConfigs", []) if ( @@ -174,8 +216,10 @@ class TorqueEVStartup(HyundaiECUStartup): return False return True - def before_control(self, *, configured, sources_current, control_current): + def before_control(self, *, configured=False, sources_current=False, control_current=False, **_context): self.check() + if self.outcome is Outcome.PENDING: + return False if self.outcome is not Outcome.SENT_UNCONFIRMED: return True if not (configured and sources_current and control_current): diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index c5b81a8608..5ceb1b758c 100644 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -1484,6 +1484,14 @@ class Car: owner.ready and owner.published and owner.ci is self.CI and not owner.closed and (qualified(self.CP) or legacy_qualified(self.CP))) + def torque_ev_startup_keepalive(self): + from opendbc.car.hyundai.torque_ev_startup import TorqueEVStartup, required + from opendbc.car.hyundai.ecu_startup import Outcome + owner = getattr(getattr(self, 'vehicle_startup', None), 'owner', None) + return (isinstance(owner, TorqueEVStartup) and owner.outcome in (Outcome.PENDING, Outcome.SENT_UNCONFIRMED) and + owner.ready and owner.published and owner.ci is self.CI and not owner.closed and + required(self.CP)) + def mixed_startup_keepalive(self): from opendbc.car.hyundai.blended_longitudinal import BlendedStartup owner = getattr(getattr(self, 'vehicle_startup', None), 'owner', None) @@ -1512,7 +1520,7 @@ class Car: if initialize_only: ev6_startup = self.ev6_startup_keepalive() - classic_startup = self.classic_startup_keepalive() + classic_startup = self.classic_startup_keepalive() or self.torque_ev_startup_keepalive() mixed_startup = self.mixed_startup_keepalive() now_ns = time.monotonic_ns() source_ns = int(self.sm.logMonoTime['carControl']) @@ -1558,9 +1566,10 @@ class Car: return mixed = self.vehicle_startup.owner if self.mixed_startup_keepalive() else None from opendbc.car.hyundai.g90_startup import G90Startup + from opendbc.car.hyundai.torque_ev_startup import TorqueEVStartup from opendbc.car.hyundai.ecu_startup import Outcome classic = self.vehicle_startup.owner - if isinstance(classic, G90Startup) and classic.outcome is Outcome.PENDING: + if isinstance(classic, (G90Startup, TorqueEVStartup)) and classic.outcome is Outcome.PENDING: now = time.monotonic_ns() source = int(self.sm.logMonoTime['carControl']) receipt = int(self.sm.recv_time['carControl'] * 1e9) @@ -1748,11 +1757,11 @@ class Car: if not self.CP.passive and initialized: self.controls_update(CS, self.sm['carControl']) elif ((not self.ci_initialized or self.volt_startup_keepalive() or self.ev6_startup_keepalive() or - self.classic_startup_keepalive() or self.mixed_startup_keepalive()) and + self.classic_startup_keepalive() or self.torque_ev_startup_keepalive() or self.mixed_startup_keepalive()) and self.sm.seen['onroadEvents'] and any(e.name == EventName.selfdriveInitializing for e in self.sm['onroadEvents']) and ((self.aol_replay and native_bootstrap_supported(self.CP)) or self.ev6_startup_keepalive() or - self.classic_startup_keepalive() or self.mixed_startup_keepalive())): + self.classic_startup_keepalive() or self.torque_ev_startup_keepalive() or self.mixed_startup_keepalive())): self.controls_update(CS, self.sm['carControl'], initialize_only=True) radar_can = self._deferred_radar_can diff --git a/prebuilt.json b/prebuilt.json index 602210c254..e126105566 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "84f0eb69d2d9891e2d30b08fc5e1cc99d541250394ad36b378d896787b30ac4d", "schema_version": 1} +{"protected_inventory_sha256": "e34dc5ce97408242abb88bb2622d2e6818238101b65cbc8a64e640118694c8a2", "schema_version": 1} diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index f6bb972bf0..77e2916d26 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -24,6 +24,7 @@ from tools.ci import lifecycle_shards, vehicle_pytest # Representative production callers plus their source/native refusal cases. # The emitted selected_test_ids record exact parametrized configurations. AOL_LIFECYCLE_TARGETS = ( + "opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py", 'openpilot/starpilot/tests/test_ford_bluepilot_aol.py', 'opendbc_repo/opendbc/safety/tests/test_ford_bluepilot_aol.py', 'openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py', @@ -340,6 +341,7 @@ RELEASE_TARGETS = ( "opendbc_repo/opendbc/car/hyundai/tests/test_classic_aol_dash.py", "opendbc_repo/opendbc/car/hyundai/tests/test_g90_continuity.py", "opendbc_repo/opendbc/car/hyundai/tests/test_classic_deferred_takeover.py", + "opendbc_repo/opendbc/car/hyundai/tests/test_torque_ev_deferred_takeover.py::test_release_native_refuses_alpha_torque_long", "opendbc_repo/opendbc/car/hyundai/tests/test_disable_response.py", "openpilot/starpilot/aol/tests/test_angle_stop_native_feedback.py", "openpilot/starpilot/aol/tests/test_angle_stop_fault_guards.py",