From 1973ad643919068dac2a7299e65e2db3da91c911 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:37:46 -0500 Subject: [PATCH] Defer EV9 ECU takeover until replacement controls are ready --- .../opendbc/car/hyundai/carcontroller.py | 5 +- opendbc_repo/opendbc/car/hyundai/carstate.py | 2 +- .../car/hyundai/ev9_pending_startup.py | 44 ++ opendbc_repo/opendbc/car/hyundai/interface.py | 4 +- .../opendbc/car/hyundai/ioniq5pe_startup.py | 6 +- .../hyundai/tests/test_ev9_long_controller.py | 4 + .../tests/test_ev9_pending_protocol.py | 64 ++ .../hyundai/tests/test_ev9_pending_startup.py | 595 ++++++++++++++++++ .../car/hyundai/tests/test_ev9_startup.py | 43 +- .../tests/testdata/ev9_startup_capture.json | 1 + .../safety/tests/test_ev9_pending_native.py | 101 +++ prebuilt.json | 2 +- tools/ci/run_vehicle_tests.py | 2 + 13 files changed, 845 insertions(+), 28 deletions(-) create mode 100644 opendbc_repo/opendbc/car/hyundai/ev9_pending_startup.py create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_protocol.py create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_startup.py create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/testdata/ev9_startup_capture.json create mode 100644 opendbc_repo/opendbc/safety/tests/test_ev9_pending_native.py diff --git a/opendbc_repo/opendbc/car/hyundai/carcontroller.py b/opendbc_repo/opendbc/car/hyundai/carcontroller.py index 2444ae8534..910c16221d 100644 --- a/opendbc_repo/opendbc/car/hyundai/carcontroller.py +++ b/opendbc_repo/opendbc/car/hyundai/carcontroller.py @@ -160,9 +160,10 @@ class CarController(CarControllerBase): self._ray_disengage_frame = None def update(self, CC, CS, now_nanos): - if self.pe_longitudinal is not None and (self.pe_startup is None or not self.pe_startup.can_emit(self)): + pending_startup_required = self.pe_longitudinal is not None or self.ev9_longitudinal is not None + if pending_startup_required and (self.pe_startup is None or not self.pe_startup.can_emit(self)): return structs.CarControl.Actuators.new_message(), [] - if self.pe_longitudinal is not None and not self.pe_startup.handed_off: + if pending_startup_required and not self.pe_startup.handed_off: CC = structs.CarControl.new_message().as_reader() _projection = self.learning_projection_observer if _projection is not None: diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index bce3c67790..aa53758908 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -70,7 +70,7 @@ class CarState(CarStateBase): self.long_buttons = (LongCruiseButtons(track_main=CP.carFingerprint != CAR.HYUNDAI_IONIQ_5_PE) if long_buttons_qualified(CP) else None) self.pe_long = pe_long_qualified(CP) - self.pe_ready = PEReadySource() if self.pe_long else None + self.pe_ready = PEReadySource() if self.pe_long or ev9_long_qualified(CP) else None self.ev9_long = ev9_long_qualified(CP) or self.pe_long self.ev9_camera_lead = (CANFDCameraLead(CP, CanBus(CP).ECAN) if self.pe_long else EV9CameraLead(CP) if self.ev9_long else None) diff --git a/opendbc_repo/opendbc/car/hyundai/ev9_pending_startup.py b/opendbc_repo/opendbc/car/hyundai/ev9_pending_startup.py new file mode 100644 index 0000000000..a04b36714a --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/ev9_pending_startup.py @@ -0,0 +1,44 @@ +"""EV9 pending startup retains source UDS outcomes and waits for replacement controls.""" +import time + +from opendbc.car.hyundai.ev9_longitudinal import qualified +from opendbc.car.hyundai.ioniq5pe_startup import PEPendingStartup +from opendbc.car.isotp_parallel_query import IsoTpParallelQuery + + +class EV9PendingStartup(PEPendingStartup): + @staticmethod + def profile_qualified(cp): + return qualified(cp) + + def __init__(self, cp, callbacks, *, stock_cp, exchange=None, **kwargs): + self.disable_response = None + super().__init__(cp, callbacks, stock_cp=stock_cp, + exchange=self._ev9_exchange if exchange is None else exchange, **kwargs) + + def _ev9_exchange(self, recv, send, request, response): + attempts = 10 if request == b"\x10\x03" else 1 + for _ in range(attempts): + query = IsoTpParallelQuery(send, recv, 1, [0x730], [request], [b""]) + replies = query.get_data(.1, total_timeout=.1) + if request == b"\x28\x01\x01": + if not replies: + # Silence is sent, unconfirmed; the inherited post-request quiet and + # independent-source proof must still pass before ownership commits. + self.disable_response = 'no_reply_sent_unconfirmed' + return True + valid = all(payload == b"\x68\x01" for payload in replies.values()) + self.disable_response = 'positive_communication_control' if valid else 'negative_or_malformed_reply' + return valid + payload = replies.get((0x730, None)) + if request in (b"\x10\x03", b"\x10\x01"): + valid = payload is not None and payload.startswith(response) and len(payload) in (2, 6) + else: + valid = payload == response + if valid: + if request == b"\x10\x03": + time.sleep(.05) + return True + if request == b"\x10\x03": + time.sleep(.1) + return False diff --git a/opendbc_repo/opendbc/car/hyundai/interface.py b/opendbc_repo/opendbc/car/hyundai/interface.py index 9287616baf..08e47f8daa 100644 --- a/opendbc_repo/opendbc/car/hyundai/interface.py +++ b/opendbc_repo/opendbc/car/hyundai/interface.py @@ -74,10 +74,10 @@ class CarInterface(CarInterfaceBase): if gv70_required(cp): return GV70Startup(cp, callbacks) if requested else None from opendbc.car.hyundai.ev9_longitudinal import candidate as ev9_candidate, qualified as ev9_qualified - from opendbc.car.hyundai.ev9_startup import EV9Startup + from opendbc.car.hyundai.ev9_pending_startup import EV9PendingStartup ev9_cp = ev9_candidate(cp, enabled=requested, is_release=False) if ev9_qualified(ev9_cp): - return EV9Startup(ev9_cp, callbacks, stock_cp=cp) if requested else None + return EV9PendingStartup(ev9_cp, callbacks, stock_cp=cp) if requested else None from opendbc.car.hyundai.torque_ev_startup import candidate as torque_ev_candidate, TorqueEVStartup torque_cp = torque_ev_candidate(cp, requested=requested, is_release=False) if torque_cp is not None: diff --git a/opendbc_repo/opendbc/car/hyundai/ioniq5pe_startup.py b/opendbc_repo/opendbc/car/hyundai/ioniq5pe_startup.py index 91a50791e5..66cbca63dd 100644 --- a/opendbc_repo/opendbc/car/hyundai/ioniq5pe_startup.py +++ b/opendbc_repo/opendbc/car/hyundai/ioniq5pe_startup.py @@ -40,10 +40,14 @@ class Lease: class PEPendingStartup: + @staticmethod + def profile_qualified(cp): + return qualified(cp) + def __init__(self, cp, callbacks, *, stock_cp, clock=time.monotonic, boot_clock=clock_boot_ns, exchange=query_ioniq6_adas, event_factory=threading.Event, thread_factory=threading.Thread): - if not qualified(cp) or not stock_qualified(stock_cp): + if not self.profile_qualified(cp) or not stock_qualified(stock_cp) or stock_cp.carFingerprint != cp.carFingerprint: raise ValueError('PE startup requires exact separately prepared PE LONG and stock CP') self.owner = Lease(copy_cp(cp)) self.stock_cp = copy_cp(stock_cp) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_long_controller.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_long_controller.py index c562ea958e..33fea9118e 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_long_controller.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_long_controller.py @@ -1,5 +1,6 @@ """EV9 optional LONG actual parser/controller and reached-cadence boundaries.""" import unittest +from types import SimpleNamespace from opendbc.can import CANPacker from opendbc.car import Bus, structs from opendbc.car.hyundai.ev9_longitudinal import candidate, qualified, EV9LongitudinalPolicy, LongCtrlState @@ -41,6 +42,9 @@ class TestEV9LongController(unittest.TestCase): cp = candidate(params(candidate=CAR.KIA_EV9), enabled=True, is_release=False) self.assertTrue(qualified(cp)) ci = CarInterface(cp) + # This fixture isolates controller calibration after a controlled owner + # acknowledgment. Pending-owner/first-native-output tests use the real owner. + ci.CC.pe_startup = SimpleNamespace(handed_off=True, can_emit=lambda controller: True) packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) ci.update([]) for tick in range(1, 13): diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_protocol.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_protocol.py new file mode 100644 index 0000000000..b1db2de34b --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_protocol.py @@ -0,0 +1,64 @@ +"""EV9 source UDS distinctions through the real ISO-TP decoder.""" +from unittest.mock import patch + +import pytest + +from opendbc.car.can_definitions import CanData +from opendbc.car.hyundai.ev9_longitudinal import candidate +from opendbc.car.hyundai.ev9_pending_startup import EV9PendingStartup +from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params +from opendbc.car.hyundai.values import CAR + + +@pytest.mark.parametrize('reply,accepted,outcome', [ + (b'\x68\x01', True, 'positive_communication_control'), + (None, True, 'no_reply_sent_unconfirmed'), + (b'\x7f\x28\x22', False, 'negative_or_malformed_reply'), + (b'\x7f\x28\x78', False, 'negative_or_malformed_reply'), + (b'\x68\x03', False, 'negative_or_malformed_reply'), + (b'\x68\x01\x00', False, 'negative_or_malformed_reply'), +]) +def test_real_decoder_does_not_turn_silence_or_nrc_into_positive_ack(reply, accepted, outcome): + stock = params(candidate=CAR.KIA_EV9) + cp = candidate(stock, enabled=True, is_release=False) + sent, pending = [], [] + + def receive(**kwargs): + frames = pending[:] + pending.clear() + return [frames] if frames else [] + + def send(frames): + sent.extend(frames) + if reply is not None: + pending.append(CanData(0x738, bytes((len(reply),)) + reply + bytes(7 - len(reply)), 1)) + + owner = EV9PendingStartup(cp, (receive, send), stock_cp=stock) + assert owner._ev9_exchange(receive, send, b'\x28\x01\x01', b'\x68\x01') is accepted + assert owner.disable_response == outcome + assert sent == [CanData(0x730, b'\x03\x28\x01\x01\x00\x00\x00\x00', 1)] + assert not owner.owner.active() + assert not owner.handed_off + + +def test_session_success_retains_source_50ms_settle(): + stock = params(candidate=CAR.KIA_EV9) + owner = EV9PendingStartup(candidate(stock, enabled=True, is_release=False), (lambda **kw: [], lambda frames: None), stock_cp=stock) + with patch('opendbc.car.hyundai.ev9_pending_startup.IsoTpParallelQuery') as query, \ + patch('opendbc.car.hyundai.ev9_pending_startup.time.sleep') as sleep: + query.return_value.get_data.return_value = {(0x730, None): b'\x50\x03\x00\x32\x01\xf4'} + assert owner._ev9_exchange(lambda **kw: [], lambda frames: None, b'\x10\x03', b'\x50\x03') + query.return_value.get_data.assert_called_once_with(.1, total_timeout=.1) + sleep.assert_called_once_with(.05) + + +def test_missing_session_retains_ten_bounded_source_attempts_without_disable(): + stock = params(candidate=CAR.KIA_EV9) + owner = EV9PendingStartup(candidate(stock, enabled=True, is_release=False), (lambda **kw: [], lambda frames: None), stock_cp=stock) + with patch('opendbc.car.hyundai.ev9_pending_startup.IsoTpParallelQuery') as query, \ + patch('opendbc.car.hyundai.ev9_pending_startup.time.sleep') as sleep: + query.return_value.get_data.return_value = {} + assert not owner._ev9_exchange(lambda **kw: [], lambda frames: None, b'\x10\x03', b'\x50\x03') + assert query.call_count == sleep.call_count == 10 + assert all(call.args[4] == [b'\x10\x03'] for call in query.call_args_list) + assert owner.disable_response is None diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_startup.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_startup.py new file mode 100644 index 0000000000..cfb4f8d847 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_startup.py @@ -0,0 +1,595 @@ +import unittest +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch + +from opendbc.can import CANPacker +from opendbc.car import Bus, CanData, structs +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.ev9_longitudinal import candidate, qualified +from opendbc.car.hyundai.ev9_pending_startup import EV9PendingStartup as PEPendingStartup +from opendbc.car.hyundai.ioniq5pe_startup import Phase +from opendbc.car.hyundai.ioniq5pe_ready import PEReadySource +from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum +from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params as stock_params +from opendbc.car.hyundai.tests.test_ev9_long_controller import packets +from opendbc.car.hyundai.values import CAR, DBC +from opendbc.car.hyundai.ioniq6_handoff import TimestampedCanPacket +from openpilot.selfdrive.car.card import Car + + +def params(): + return stock_params(candidate=CAR.KIA_EV9) + + +class ThreadFixture: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.started = self.joined = False + + def start(self): + self.started = True + + def join(self): + self.joined = True + + +class EV9StartupFixture(unittest.TestCase): + def setUp(self): + self.boot = 1_000_000_000 + self.seconds = 1. + self.sent = [] + self.queries = [] + self.admitted = True + self.disable_ok = True + self.restore_ok = True + self.tick = 0 + boot = patch('opendbc.car.hyundai.ioniq5pe_ready.clock_boot_ns', side_effect=lambda: self.boot) + boot.start() + self.addCleanup(boot.stop) + self.stock = params() + self.stock_before = self.stock.to_dict() + self.cp = candidate(self.stock, enabled=True, is_release=False) + self.assertTrue(qualified(self.cp)) + + def exchange(self, recv, send, request, response): + self.queries.append((request, response)) + if request == b'\x28\x01\x01': + return self.disable_ok + if request == b'\x28\x00\x01': + return self.restore_ok + return (request, response) in ((b'\x10\x03', b'\x50\x03'), (b'\x10\x01', b'\x50\x01')) + + def instance(self, ready=False, *, seal=True): + packer = CANPacker(DBC[CAR.KIA_EV9][Bus.pt]) + + def recv(**kwargs): + + self.tick += 1 + self.boot += 10_000_000 + self.seconds += .01 + return [TimestampedCanPacket(packets(packer, self.tick, advance_counter=True), self.boot)] + + owner = PEPendingStartup(self.cp, (recv, self.sent.extend), stock_cp=self.stock, + clock=lambda: self.seconds, boot_clock=lambda: self.boot, + exchange=self.exchange, thread_factory=ThreadFixture) + with patch.object(owner, '_ready_probe', return_value=ready): + cp = owner.prepare(admission=lambda: self.admitted) + ci = CarInterface(cp) + owner.configure(ci) + owner.finalize_aol_configuration(ci) + if seal: + owner.seal_publication() + return owner, ci, packer + + def warm(self, owner, ci, packer): + for _ in range(12): + self.tick += 1 + self.boot += 10_000_000 + ci.update([(self.boot, packets(packer, self.tick, advance_counter=True))]) + self.assertTrue(ci.CS.out.canValid) + self.assertTrue(owner.sources_current(ci, self.boot)) + + def acknowledge(self, owner, ci): + self.admitted = False + config = ci.CP.safetyConfigs[0] + panda = SimpleNamespace(safetyModel=config.safetyModel, safetyParam=config.safetyParam, + alternativeExperience=0, safetyRxChecksInvalid=False, controlsAllowed=False, + faults=[], ignitionLine=True, ignitionCan=False) + owner.after_state(ci=ci, state=ci.CS.out, now_ns=self.boot, control_enabled=False, + control_current=True, pandas=[panda], panda_log_ns=self.boot, + panda_recv_ns=self.boot, panda_current=True) + + def commit(self, owner, ci): + owner.commit_pending(ci, sources_current=owner.sources_current(ci, self.boot), control_current=True) + + +class TestEV9PendingStartup(EV9StartupFixture, unittest.TestCase): + def test_prepare_preserves_factory_output_and_never_sends_disable_or_keeper(self): + owner, ci, _ = self.instance() + self.assertTrue(owner.pending_target) + self.assertFalse(owner.owner.active()) + self.assertEqual(self.queries, []) + self.assertEqual(self.sent, []) + self.assertIsNone(owner.thread) + self.assertEqual(self.stock.to_dict(), self.stock_before) + cc = structs.CarControl(enabled=True, latActive=True, longActive=True) + cc.actuators.accel = 1. + _, frames = ci.apply(cc.as_reader(), self.boot) + self.assertEqual(frames, []) + + def test_ready_or_missing_ready_evidence_returns_exact_stock_without_ecu_mutation(self): + for ready in (True, None): + owner, ci, _ = self.instance(ready) + self.assertEqual(ci.CP.to_dict(), self.stock_before) + self.assertEqual(owner.owner.phase, Phase.STOCK) + self.assertFalse(owner.pending_target) + self.assertEqual(self.queries, []) + self.assertEqual(self.sent, []) + owner.close() + + def test_pending_commit_requires_inactive_host_native_diagnostic_and_current_sources(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + for source, control in ((False, True), (True, False)): + with self.assertRaises(RuntimeError): + owner.commit_pending(ci, sources_current=source, control_current=control) + self.admitted = False + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertEqual(self.queries, []) + self.assertFalse(owner.takeover_attempted) + + def test_late_ready_before_commit_aborts_published_target_without_disable(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.boot += 10_000_000 + address, payload, bus = packer.make_can_msg('ACCELERATOR', 1, {'COUNTER': 13, 'GEAR': 5}) + raw = bytearray(payload) + raw[3] |= 0x40 + raw[:2] = hkg_can_fd_checksum(address, None, raw).to_bytes(2, 'little') + ci.update([(self.boot, [(address, bytes(raw), bus)])]) + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertEqual(self.queries, []) + self.assertFalse(owner.takeover_attempted) + self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5c95) + + def test_negative_or_silent_disable_restores_and_aborts_without_relabeling_cp(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.disable_ok = False + before = ci.CP.to_dict() + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertTrue(owner.takeover_attempted) + self.assertTrue(owner.restored) + self.assertIn((b'\x28\x00\x01', b'\x68\x00'), self.queries) + self.assertEqual(ci.CP.to_dict(), before) + self.assertFalse(owner.can_emit(ci.CC)) + + def test_positive_ack_with_continued_physical_scc_aborts_and_restores(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + recv = owner.callbacks[0] + + def still_sending(**kwargs): + result = recv(**kwargs) + result[0].append(packer.make_can_msg('SCC_CONTROL', 1, {'ACCMode': 0})) + return result + + owner.callbacks = (still_sending, owner.callbacks[1]) + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertTrue(owner.restored) + self.assertFalse(owner.can_emit(ci.CC)) + self.assertEqual(owner.quiet_failure['address'], '0x1a0') + self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5c95) + + def test_silence_without_postack_independent_can_is_not_ownership_proof(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + + def empty(**kwargs): + self.seconds += .01 + self.boot += 10_000_000 + return [] + + owner.callbacks = (empty, owner.callbacks[1]) + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertTrue(owner.restored) + self.assertFalse(owner.owner.active()) + + def test_uncertain_restoration_stays_abort_and_cannot_claim_stock(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.disable_ok = self.restore_ok = False + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertFalse(owner.restored) + self.assertEqual(owner.owner.phase, Phase.ABORT) + with self.assertRaises(RuntimeError): + owner.close() + self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5c95) + + def test_postack_sources_and_native_receipt_gate_actual_controller_then_neutral_send_handoff(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + self.assertFalse(owner.sources_current(ci, self.boot)) + cc = structs.CarControl.new_message() + _, frames = ci.apply(cc.as_reader(), self.boot) + self.assertEqual(frames, []) + self.warm(owner, ci, packer) + self.assertFalse(owner.can_emit(ci.CC)) + self.acknowledge(owner, ci) + self.assertTrue(owner.can_emit(ci.CC)) + self.assertTrue(owner.before_control(configured=True, sources_current=True, control_current=True)) + self.assertFalse(owner.before_control(configured=False, sources_current=True, control_current=True)) + self.assertFalse(owner.can_emit(ci.CC)) + self.acknowledge(owner, ci) + owner.sent(list(self.sent), valid=True) + self.assertFalse(owner.handed_off) + _, frames = ci.apply(cc.as_reader(), self.boot) + self.assertTrue({0x1A0, 0xCB, 0x100, 0x730}.issubset({frame[0] for frame in frames})) + owner.sent(frames, valid=False) + self.assertFalse(owner.handed_off) + owner.sent(frames, valid=True) + self.assertTrue(owner.handed_off) + self.assertTrue(owner.thread.joined) + self.admitted = True + owner.close() + self.assertTrue(owner.restored) + + def test_ev9_retains_its_current_mdps_estimate_and_fault_mask2(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + for tick, fault in enumerate((0, 1, 2, 4, 7), 13): + self.boot += 10_000_000 + frame = packer.make_can_msg('MDPS', 1, {'MDPS_EstStrAnglVal': 12.3, 'MDPS_PaStrAnglVal': -45.6, + 'MDPS_ADAS_AciFltSig_Lv2': fault, 'COUNTER': tick}) + ci.update([(self.boot, [frame])]) + self.assertAlmostEqual(ci.CS.angle_steering_angle, 12.3) + self.assertEqual(ci.CS.angle_steering_fault, bool(fault & 2)) + + def test_first_replacement_deadline_is_software_guard_not_infinite_tester_lease(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + self.seconds += 1. + owner._tester() + with self.assertRaises(RuntimeError): + owner.check() + self.assertFalse(owner.can_emit(ci.CC)) + + def test_uds_time_is_included_in_first_replacement_software_bound(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + exchange = owner.exchange + + def delayed_ack(recv, send, request, response): + result = exchange(recv, send, request, response) + if request == b'\x28\x01\x01': + self.seconds += 1. + return result + + owner.exchange = delayed_ack + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertTrue(owner.restored) + self.assertEqual(self.sent, []) + self.assertFalse(owner.can_emit(ci.CC)) + + def test_invalid_emitted_replacement_cannot_transfer_keeper_as_if_native_accepted(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + self.warm(owner, ci, packer) + self.acknowledge(owner, ci) + _, frames = ci.apply(structs.CarControl.new_message().as_reader(), self.boot) + bad = [] + for address, data, bus in frames: + if address == 0x1A0: + raw = bytearray(data) + raw[0] ^= 1 + data = bytes(raw) + bad.append((address, data, bus)) + owner.sent(bad, valid=True) + self.assertFalse(owner.handed_off) + self.assertFalse(owner.can_emit(ci.CC)) + with self.assertRaises(RuntimeError): + owner.check() + + def test_actual_card_teardown_quiesces_and_returns_to_diagnostic_mode_before_restore(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + self.admitted = False + writes = [] + + def put_bool(key, value): + writes.append((key, value)) + self.assertTrue(owner.closed) + self.admitted = key == 'ControlsReady' and value is False + + caller = SimpleNamespace(vehicle_startup=SimpleNamespace(owner=owner), + params=SimpleNamespace(put_bool=put_bool), startup_diagnostic_admission=lambda: self.admitted) + self.assertTrue(Car.prepare_pe_restoration(cast(Car, caller))) + owner.close() + self.assertTrue(owner.restored) + self.assertEqual(writes, [('ControlsReady', False)]) + self.assertEqual(self.queries[-1], (b'\x10\x01', b'\x50\x01')) + self.assertEqual(self.stock.to_dict(), self.stock_before) + + def test_diagnostic_reentry_timeout_never_sends_restore_under_tester_only_native(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + self.admitted = False + count = len(self.queries) + timer = [0.] + + def now(): + timer[0] += .1 + return timer[0] + + writes = [] + caller = SimpleNamespace(vehicle_startup=SimpleNamespace(owner=owner), + params=SimpleNamespace(put_bool=lambda key, value: writes.append((key, value))), + startup_diagnostic_admission=lambda: False) + with patch('openpilot.selfdrive.car.card.time.monotonic', side_effect=now), patch('openpilot.selfdrive.car.card.time.sleep'): + self.assertFalse(Car.prepare_pe_restoration(cast(Car, caller))) + with self.assertRaises(RuntimeError): + owner.close() + self.assertEqual(len(self.queries), count) + self.assertFalse(owner.restored) + self.assertEqual(writes, [('ControlsReady', False)]) + + def test_stock_fallback_preserves_existing_angle_aol_marker_only(self): + owner, ci, _ = self.instance(True, seal=False) + ci.CP.alternativeExperience = 32 + owner.finalize_aol_configuration(ci) + owner.seal_publication() + self.assertTrue(owner.prepared_for(ci.CP)) + self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5c91) + self.assertEqual(self.stock.to_dict(), self.stock_before) + + def test_restore_admission_exception_is_uncertain_and_never_relabels_stock(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.commit(owner, ci) + before = ci.CP.to_dict() + count = len(self.queries) + + def failed_transport(): + raise OSError('Explicit diagnostic-admission fixture failure') + + owner.diagnostic_admission = failed_transport + with self.assertRaises(RuntimeError): + owner.close() + self.assertTrue(owner.closed) + self.assertFalse(owner.restored) + self.assertEqual(owner.owner.phase, Phase.ABORT) + self.assertEqual(len(self.queries), count) + self.assertEqual(ci.CP.to_dict(), before) + + +class TestPEReadyPhysicalSource(unittest.TestCase): + def test_missing_boottime_does_not_create_ready_false_from_defaults(self): + source = PEReadySource() + source.observation = (1_000_000_000, False) + with patch('opendbc.car.hyundai.ioniq5pe_ready.clock_boot_ns', return_value=0): + source.observe([]) + self.assertIsNone(source.current(1_000_000_000, 0, 100_000_000)) + + def test_crc_counter_bus_and_freshness_require_actual_physical_sample(self): + packer = CANPacker('hyundai_canfd_generated') + source = PEReadySource() + for stamp, count in ((1_010_000_000, 0), (1_020_000_000, 1)): + message = packer.make_can_msg('ACCELERATOR', 1, {'COUNTER': count, 'GEAR': 5}) + source.update([(stamp, [message])], stamp) + self.assertIs(source.current(1_020_000_000, 1_000_000_000, 100_000_000), False) + self.assertIs(source.current(1_120_000_000, 1_000_000_000, 100_000_000), False) + self.assertIsNone(source.current(1_120_000_001, 1_000_000_000, 100_000_000)) + address, data, _ = packer.make_can_msg('ACCELERATOR', 1, {'COUNTER': 2}) + wrong_bus = (address, data, 0) + source.update([(1_030_000_000, [wrong_bus])], 1_030_000_000) + assert source.observation is not None + self.assertEqual(source.observation[0], 1_020_000_000) + corrupted = bytearray(data) + corrupted[0] ^= 1 + source.update([(1_030_000_000, [(address, bytes(corrupted), 1)])], 1_030_000_000) + self.assertIsNone(source.current(1_030_000_000, 1_000_000_000, 100_000_000)) + +class TestEV9SessionAndReadyProbe(EV9StartupFixture, unittest.TestCase): + def test_uncertain_session_alone_restores_default_without_communication_control(self): + for outcome in (False, None, OSError('session send uncertain')): + with self.subTest(outcome=outcome): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + before = ci.CP.to_dict() + self.queries.clear() + + def uncertain_session(recv, send, request, response, outcome=outcome): + self.queries.append((request, response)) + if request == b'\x10\x03': + if isinstance(outcome, Exception): + raise outcome + return outcome + return request == b'\x10\x01' and response == b'\x50\x01' + + owner.exchange = uncertain_session + with self.assertRaises((RuntimeError, OSError)): + self.commit(owner, ci) + self.assertTrue(owner.session_attempted) + self.assertFalse(owner.takeover_attempted) + self.assertTrue(owner.restored) + self.assertEqual(self.queries, [(b'\x10\x03', b'\x50\x03'), (b'\x10\x01', b'\x50\x01')]) + self.assertEqual(ci.CP.to_dict(), before) + self.assertFalse(owner.can_emit(ci.CC)) + + def test_session_only_uncertain_default_session_stays_unverified_and_reenters_diagnostics(self): + owner, ci, packer = self.instance() + self.warm(owner, ci, packer) + self.queries.clear() + owner.exchange = lambda recv, send, request, response: self.queries.append((request, response)) + with self.assertRaises(RuntimeError): + self.commit(owner, ci) + self.assertTrue(owner.session_attempted) + self.assertFalse(owner.takeover_attempted) + self.assertFalse(owner.restored) + self.admitted = False + writes = [] + + def put_bool(key, value): + writes.append((key, value)) + self.admitted = key == 'ControlsReady' and value is False + + caller = SimpleNamespace(vehicle_startup=SimpleNamespace(owner=owner), + params=SimpleNamespace(put_bool=put_bool), startup_diagnostic_admission=lambda: self.admitted) + self.assertTrue(Car.prepare_pe_restoration(cast(Car, caller))) + with self.assertRaises(RuntimeError): + owner.close() + with self.assertRaises(RuntimeError): + owner.close() + self.assertFalse(owner.restored) + self.assertEqual(writes, [('ControlsReady', False)]) + self.assertTrue(all(request in (b'\x10\x03', b'\x10\x01') for request, _ in self.queries)) + + def test_live_prepare_probe_revokes_cached_false_after_later_invalid_source(self): + for invalid in (None, 'crc', 'timestamp'): + with self.subTest(invalid=invalid): + owner, _, packer = self.instance() + ticks = 0 + + def recv(*, packer=packer, invalid=invalid, **kwargs): + nonlocal ticks + ticks += 1 + self.boot += 10_000_000 + self.seconds += .01 + frame = next(f for f in packets(packer, ticks, advance_counter=True) if f[0] == 0x35) + if invalid == 'crc' and ticks >= 45: + data = bytearray(frame[1]) + data[-1] ^= 1 + frame = CanData(frame[0], bytes(data), frame[2]) + stamp = 0 if invalid == 'timestamp' and ticks >= 45 else self.boot + return [TimestampedCanPacket([frame], stamp)] + + owner.callbacks = (recv, self.sent.extend) + with patch('opendbc.car.hyundai.ioniq5pe_startup.time.sleep'): + result = owner._ready_probe() + self.assertIs(result, False if invalid is None else None) + self.assertEqual(self.queries, []) + + +class TestEV9NormalSelection(unittest.TestCase): + def test_saved_choice_and_captured_ev9_factory_reach_sealed_owner_without_setting_writes(self): + from opendbc.car import gen_empty_fingerprint + from opendbc.car.car_helpers import get_car + from opendbc.car.vin import VIN_UNKNOWN + import json + from pathlib import Path + FIXTURE = json.loads((Path(__file__).parent / 'testdata' / 'ev9_startup_capture.json').read_text()) + from openpilot.selfdrive.car.card import alpha_long_requested + from openpilot.starpilot.vehicle_preferences import VehicleStartupPreferences + from openpilot.starpilot.vehicle_startup import VehicleStartupOwner + + from tempfile import TemporaryDirectory + from pathlib import Path + + class Saved: + def __init__(self, alpha): + self.values = {'AlwaysOnLateral': b'1', 'LongPitch': b'0'} + if alpha is not None: + self.values['AlphaLongitudinalEnabled'] = alpha + self.directory = TemporaryDirectory() + self.root = Path(self.directory.name) + for key, value in self.values.items(): + (self.root / key).write_bytes(value) + + def get_param_path(self, key): + return str(self.root / key) + + def get(self, key, **kwargs): + path = self.root / key + raw = path.read_bytes() if path.exists() else None + return raw.decode(kwargs["encoding"]) if raw is not None and kwargs.get("encoding") else raw + + def get_bool(self, key): + return self.get(key) == b'1' + + for alpha, release in ((None, False), (b'0', False), (b'1', False), (b'1', True)): + with self.subTest(alpha=alpha, release=release): + saved = Saved(alpha) + self.addCleanup(saved.directory.cleanup) + before_files = {p.name: p.read_bytes() for p in saved.root.iterdir()} + before = dict(saved.values) + request = alpha_long_requested(saved, is_release=release) + startup = VehicleStartupOwner() + preferences = VehicleStartupPreferences.read(saved, enabled=True) + finger = gen_empty_fingerprint() + for bus, entries in FIXTURE['fingerprint'].items(): + finger[int(bus)].update({int(addr): length for addr, length in entries.items()}) + fw = [] + + def receive(wait_for_one: bool = False) -> list[list[CanData]]: + return [] + + def before_create(cp, candidate, fingerprints, car_fw, *, preferences=preferences, startup=startup, request=request): + cp = preferences.prepare(cp, fingerprints=fingerprints) + return startup.prepare(cp, CarInterface, (receive, lambda frames: None), + requested=request, admission=lambda: True) + + detected = (CAR.KIA_EV9, finger, VIN_UNKNOWN, fw, structs.CarParams.FingerprintSource.fw, True) + with patch('opendbc.car.car_helpers.fingerprint', return_value=detected), \ + patch.object(PEPendingStartup, '_ready_probe', return_value=False): + ci = get_car(receive, lambda frames: None, lambda enabled: None, + request, release, pre_create_hook=before_create) + startup.configure(ci) + startup.finalize_aol_configuration(ci) + startup.seal_publication() + self.assertEqual(ci.CP.openpilotLongitudinalControl, request) + self.assertEqual(ci.CP.alphaLongitudinalAvailable, not release) + self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5c95 if request else 0x5c91) + self.assertEqual(saved.values, before) + self.assertEqual({p.name: p.read_bytes() for p in saved.root.iterdir()}, before_files) + if request: + self.assertIsInstance(startup.owner, PEPendingStartup) + self.assertEqual(startup.owner.owner.phase, Phase.PENDING) + self.assertTrue(startup.owner.prepared_for(ci.CP)) + self.assertTrue(startup.owner.owner.published) + else: + self.assertIsNone(startup.owner) + + +class TestEV9PendingIdentity(unittest.TestCase): + def test_pending_owner_rejects_a_different_vehicle_stock_fallback(self): + long = candidate(params(), enabled=True, is_release=False) + with self.assertRaises(ValueError): + PEPendingStartup(long, (lambda **kw: [], lambda frames: None), stock_cp=stock_params()) + + def test_pe_owner_also_rejects_an_ev9_stock_fallback(self): + from opendbc.car.hyundai.ioniq5pe_startup import PEPendingStartup as OriginalPEPendingStartup + from opendbc.car.hyundai.ioniq5pe_long_profile import candidate as pe_candidate + + stock = stock_params() + long = pe_candidate(stock, enabled=True, is_release=False) + with self.assertRaises(ValueError): + OriginalPEPendingStartup(long, (lambda **kw: [], lambda frames: None), stock_cp=params()) + + +class TestEV9UnpreparedController(unittest.TestCase): + def test_current_inputs_do_not_enable_replacements_before_qualified_owner(self): + stock = params() + cp = candidate(stock, enabled=True, is_release=False) + ci = CarInterface(cp) + ci.update([]) + packer = CANPacker(DBC[CAR.KIA_EV9][Bus.pt]) + for tick in range(1, 13): + ci.update([(tick * 10_000_000, packets(packer, tick, advance_counter=True))]) + self.assertTrue(ci.CS.out.canValid) + control = structs.CarControl(enabled=True, latActive=True, longActive=True) + control.actuators.accel = 1. + _, frames = ci.apply(control.as_reader(), 130_000_000) + self.assertEqual(frames, []) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_startup.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_startup.py index 4086e5696a..029e38b60e 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_startup.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ev9_startup.py @@ -11,6 +11,7 @@ from opendbc.car.can_definitions import CanData from opendbc.car.hyundai.ecu_startup import Outcome from opendbc.car.hyundai.ev9_longitudinal import candidate, qualified from opendbc.car.hyundai.ev9_startup import EV9Startup +from opendbc.car.hyundai.ev9_pending_startup import EV9PendingStartup from opendbc.car.hyundai.interface import CarInterface from opendbc.car.hyundai.ioniq6_handoff import TimestampedCanPacket from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params @@ -209,8 +210,8 @@ class TestEV9Startup(unittest.TestCase): def test_actual_factory_promotes_only_exact_stock_cp(self): stock = params(candidate=CAR.KIA_EV9) actual = CarInterface.startup_owner(stock, (lambda **kwargs: [], lambda frames: None), requested=True) - self.assertIsInstance(actual, EV9Startup) - self.assertTrue(qualified(actual.cp)) + self.assertIsInstance(actual, EV9PendingStartup) + self.assertTrue(qualified(actual.owner.cp)) self.assertEqual(actual.stock_cp.to_dict(), stock.to_dict()) self.assertFalse(candidate(stock, enabled=False, is_release=False).openpilotLongitudinalControl) self.assertFalse(candidate(stock, enabled=True, is_release=True).openpilotLongitudinalControl) @@ -258,11 +259,18 @@ class TestEV9Startup(unittest.TestCase): self.assertEqual(owner._disable(owner.callbacks[0], owner.callbacks[1]), expected) @patch.object(time, "CLOCK_BOOTTIME", getattr(time, "CLOCK_BOOTTIME", time.CLOCK_MONOTONIC), create=True) - def test_actual_get_car_finalizes_long_before_interface_construction(self): + def test_actual_get_car_seals_pending_long_without_early_disable(self): from opendbc.car import car_helpers, gen_empty_fingerprint + from opendbc.car.hyundai.ioniq5pe_startup import Phase from openpilot.starpilot.vehicle_startup import VehicleStartupOwner - stock, owner, sent, requests, query = self.owner() + stock = params(candidate=CAR.KIA_EV9) + sent = [] + + def receive(wait_for_one: bool = False) -> list[list[CanData]]: + return [] + + callbacks = (receive, sent.extend) fingerprint = gen_empty_fingerprint() fingerprint[1].update({0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1CF: 8, 0x1A0: 32, 0x130: 16}) fingerprint[2].update({0x110: 32, 0x362: 32}) @@ -270,28 +278,21 @@ class TestEV9Startup(unittest.TestCase): holder = VehicleStartupOwner() def hook(cp, candidate_id, fingerprints, firmware): - return holder.prepare(cp, CarInterface, owner.callbacks, requested=True, admission=lambda: True) + return holder.prepare(cp, CarInterface, callbacks, requested=True, admission=lambda: True) - with ( - patch.object(car_helpers, "fingerprint", return_value=found), - patch.object(EV9Startup, "_ready", return_value=False), - patch.object(EV9Startup, "_query", side_effect=query), - ): - # Inject a non-thread keeper at the actual factory boundary. - original_factory = CarInterface.startup_owner - - def factory(cp, callbacks, *, requested): - selected = original_factory(cp, callbacks, requested=requested) - selected.keeper_factory = owner.keeper_factory - return selected - - with patch.object(CarInterface, "startup_owner", side_effect=factory): - ci = car_helpers.get_car(owner.callbacks[0], owner.callbacks[1], lambda *args: None, True, False, pre_create_hook=hook) + with patch.object(car_helpers, "fingerprint", return_value=found), \ + patch.object(EV9PendingStartup, "_ready_probe", return_value=False): + ci = car_helpers.get_car(*callbacks, lambda *args: None, True, False, pre_create_hook=hook) holder.configure(ci) holder.seal_publication() self.assertTrue(qualified(ci.CP)) self.assertEqual(ci.CP.safetyConfigs[0].safetyParam, 0x5C95) - self.assertIs(holder.owner.outcome, Outcome.SENT_UNCONFIRMED) + self.assertIsInstance(holder.owner, EV9PendingStartup) + self.assertIs(holder.owner.owner.phase, Phase.PENDING) + self.assertTrue(holder.owner.owner.published) + self.assertTrue(holder.owner.pending_target) + self.assertEqual(sent, []) + self.assertIsNone(holder.owner.thread) def test_malformed_long_cp_cannot_bypass_required_startup_owner(self): from openpilot.starpilot.vehicle_startup import VehicleStartupOwner diff --git a/opendbc_repo/opendbc/car/hyundai/tests/testdata/ev9_startup_capture.json b/opendbc_repo/opendbc/car/hyundai/tests/testdata/ev9_startup_capture.json new file mode 100644 index 0000000000..0ea8dd4bc7 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/testdata/ev9_startup_capture.json @@ -0,0 +1 @@ +{"sourceRoute":"64760999c36a2105/0000000b--0af90ff57b","fingerprint":{"0":{"272":32,"357":16,"571":32,"572":32,"573":32,"574":32,"575":32,"576":32,"577":32,"865":32,"256":24,"578":32,"579":32,"580":32,"581":32,"582":32,"583":32,"584":32,"866":32,"560":16,"565":32,"566":32,"567":32,"568":32,"569":32,"570":32,"867":32,"869":32,"1280":16,"976":32,"917":32,"933":24,"934":24,"935":24,"936":24,"937":24,"938":24,"939":24,"940":24,"941":24,"942":24,"943":24,"944":24,"945":24,"946":24,"947":24,"948":24,"949":24,"950":24,"868":32,"870":32,"951":24,"977":32,"978":32,"979":32,"980":32,"952":24,"928":32,"953":24,"954":24,"955":24,"956":24,"957":24,"958":24,"959":24,"960":24,"961":24,"962":24,"963":24,"964":24,"864":32,"896":24},"1":{"234":24,"693":32,"736":32,"74":32,"704":32,"762":32,"805":32,"293":16,"816":32,"101":32,"266":32,"410":32,"855":32,"864":32,"874":16,"384":8,"389":8,"282":16,"426":16,"549":16,"666":8,"767":8,"789":16,"906":16,"1138":8,"304":16,"347":8,"1048":8,"203":24,"96":32,"298":16,"111":8,"352":16,"160":24,"416":32,"373":24,"53":32,"245":32,"325":32,"144":32,"432":32,"522":16,"525":32,"437":32,"698":32,"826":32,"853":32,"798":8,"1099":8,"463":8,"961":8,"648":8,"769":8,"496":16,"962":8,"1037":8,"353":32,"354":32,"442":24,"480":16,"485":16,"490":32,"512":8,"837":8,"908":32,"1280":32,"1058":8,"1264":8,"1402":32,"778":32,"800":32,"721":8,"895":8,"722":8,"817":8,"723":8,"818":8,"724":8,"819":8,"820":8,"1055":8,"1204":8,"1245":8,"1239":8,"1240":8,"1247":8,"1259":8,"928":16,"592":24,"517":24,"932":8,"901":8,"1392":32,"506":32,"703":8,"1151":8,"192":32,"597":32,"741":32,"761":8,"858":8,"879":8,"687":8,"682":32,"565":32,"336":32,"501":32,"602":32,"758":8,"763":8,"799":8,"688":32,"875":16,"892":8,"896":8,"897":8,"898":8,"899":8,"900":8,"944":8,"945":8,"946":8,"1063":8,"1065":8,"1066":8,"1057":8,"1062":8,"90":32,"785":16,"802":16,"210":8,"255":8,"914":8,"915":8,"963":8,"916":8,"977":8,"992":8,"997":8,"993":8,"994":8,"1032":8,"998":8,"1050":8,"1036":8,"1051":8,"1052":8,"1061":8,"1074":8,"1072":8,"1076":8,"1077":8,"1090":8,"1088":8,"1092":8,"1097":8,"415":8,"1093":8,"1108":8,"139":8,"1160":8,"1096":8,"1161":8,"1187":8,"949":32,"934":8,"1141":8,"1174":8,"1029":8,"1040":8,"1041":8,"1042":8,"1043":8,"1044":8,"1046":8,"1047":8,"692":32,"1274":32,"204":8,"849":8,"850":8,"851":8,"881":8,"852":8,"882":8,"883":8,"884":8,"1015":8,"1016":8,"1267":8,"1266":8,"1162":16,"1167":8,"794":32,"842":32,"1008":32,"1012":8,"1027":8,"970":16,"474":32,"1013":32},"2":{"272":32,"357":16,"571":32,"572":32,"573":32,"574":32,"575":32,"576":32,"577":32,"865":32,"256":24,"578":32,"579":32,"580":32,"581":32,"582":32,"583":32,"584":32,"866":32,"560":16,"565":32,"566":32,"567":32,"568":32,"569":32,"570":32,"867":32,"869":32,"1280":16,"976":32,"917":32,"933":24,"934":24,"935":24,"936":24,"937":24,"938":24,"939":24,"940":24,"941":24,"942":24,"943":24,"944":24,"945":24,"946":24,"947":24,"948":24,"949":24,"950":24,"868":32,"870":32,"951":24,"977":32,"978":32,"979":32,"980":32,"952":24,"928":32,"953":24,"954":24,"955":24,"956":24,"957":24,"958":24,"959":24,"960":24,"961":24,"962":24,"963":24,"964":24,"864":32,"896":24},"3":{},"4":{},"5":{},"6":{},"7":{}}} diff --git a/opendbc_repo/opendbc/safety/tests/test_ev9_pending_native.py b/opendbc_repo/opendbc/safety/tests/test_ev9_pending_native.py new file mode 100644 index 0000000000..2e20e64c4f --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_ev9_pending_native.py @@ -0,0 +1,101 @@ +import unittest + +from opendbc.car import structs +from opendbc.car.hyundai.tests.test_ev9_pending_startup import EV9StartupFixture as PEStartupFixture +from opendbc.car.hyundai.tests.test_ev9_long_controller import packets +from opendbc.safety.tests.test_hyundai_ev9_long import Ev9LongFixture as PEFixture +from opendbc.safety.tests.libsafety import libsafety_py +from openpilot.selfdrive.controls.lib.longcontrol import LongControl +from openpilot.starpilot.longitudinal.extension import LongitudinalContext + + +class TestEV9PendingControllerNativeDebug(PEStartupFixture, PEFixture, unittest.TestCase): + @staticmethod + def can_packet(frame): + return libsafety_py.make_CANPacket(frame[0], frame[2], frame[1]) + + def setUp(self): + PEStartupFixture.setUp(self) + PEFixture.setUp(self) + + self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.elm327, 1), 0) + + def physical_tick(self, ci, packer, *, button=0, adaptive_main=False, native_hyundai=True): + self.tick += 1 + self.boot += 10_000_000 + self.now += 10_000 + self.safety.set_timer(self.now) + frames = packets(packer, self.tick, advance_counter=True, cruise_button=button, adaptive_main=adaptive_main) + if native_hyundai: + for frame in frames: + accepted = self.safety.safety_rx_hook(libsafety_py.make_CANPacket(frame[0], frame[2], frame[1])) + if frame[0] in (0x35, 0x175, 0xA0, 0xEA, 0x1CF): + self.assertTrue(accepted, hex(frame[0])) + ci.update([(self.boot, frames)]) + + def test_deferred_owner_to_real_controller_wire_native_then_physical_cancel(self): + owner, ci, packer = self.instance() + for _ in range(12): + self.physical_tick(ci, packer, native_hyundai=False) + self.assertTrue(ci.CS.out.canValid) + self.assertEqual(self.queries, []) + self.commit(owner, ci) + self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.hyundaiCanfd, 0x5c95), 0) + for _ in range(12): + self.physical_tick(ci, packer) + self.assertFalse(self.safety.get_controls_allowed()) + self.acknowledge(owner, ci) + cc = structs.CarControl(enabled=True, latActive=True, longActive=True) + cc.actuators.accel = 1. + cc.actuators.steeringAngleDeg = 1. + cc.actuators.longControlState = structs.CarControl.Actuators.LongControlState.pid + _, first = ci.apply(cc.as_reader(), self.boot) + scc = next(frame[1] for frame in first if frame[0] == 0x1A0) + cb = next(frame[1] for frame in first if frame[0] == 0xCB) + self.assertEqual((scc[8] >> 4) & 7, 0) + self.assertEqual((cb[3] >> 4) & 15, 1) + for frame in first: + self.assertTrue(self.safety.safety_tx_hook(self.can_packet(frame)), hex(frame[0])) + owner.sent(first, valid=True) + self.assertTrue(owner.handed_off) + # EV9 retains its original tracked MAIN policy; physical MAIN precedes SET. + while (self.tick + 1) % 2: + self.physical_tick(ci, packer) + self.physical_tick(ci, packer, adaptive_main=True) + self.physical_tick(ci, packer, adaptive_main=True) + self.physical_tick(ci, packer) + self.assertFalse(self.safety.get_controls_allowed()) + positive = None + loop = LongControl(ci.CP) + context = LongitudinalContext(has_lead=False, traffic_mode=False, custom_acceleration=False, + profile_max_accel=0., experimental_mode=False) + press_tick = self.tick + (2 if self.tick % 2 == 0 else 1) + for index in range(40): + + next_tick = self.tick + 1 + button = 2 if next_tick == press_tick else 0 + self.physical_tick(ci, packer, button=button) + self.acknowledge(owner, ci) + + active = bool(self.safety.get_controls_allowed()) + cc.actuators.accel = float(loop.update(active, ci.CS.out, 1., False, (-3.5, 2.), context=context)) + cc.actuators.longControlState = loop.long_control_state + command = cc.as_reader() if active else structs.CarControl.new_message().as_reader() + _, emitted = ci.apply(command, self.boot) + for frame in emitted: + self.assertTrue(self.safety.safety_tx_hook(self.can_packet(frame)), (index, hex(frame[0]))) + if frame[0] == 0x1A0: + data = frame[1] + accel = ((((data[17] & 7) << 8) | data[16]) - 1023) / 100. + self.assertGreaterEqual(accel, -3.5) + self.assertLessEqual(accel, 2.) + if accel > 0: + positive = frame + self.assertIsNotNone(positive) + self.assertTrue(self.safety.get_controls_allowed()) + + while (self.tick + 1) % 2: + self.physical_tick(ci, packer) + self.physical_tick(ci, packer, button=4) + self.assertFalse(self.safety.get_controls_allowed()) + self.assertFalse(self.safety.safety_tx_hook(self.can_packet(positive))) diff --git a/prebuilt.json b/prebuilt.json index b88c4dca45..fcd99f7b34 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "ef1feef62efb0cad69cc0c347b1a8157902ef4baf4410b5913afff45973dae55", "schema_version": 1} +{"protected_inventory_sha256": "06bd107160eba869ae5b5cdcee08f84d7a7c019c7faea39c45f8a58800506032", "schema_version": 1} diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index c105de0e03..6e01a3b8bc 100755 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -33,6 +33,8 @@ DEBUG_SAFETY_CLASS_TARGETS = { RELEASE_TARGETS = ( + "opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_protocol.py", + "opendbc_repo/opendbc/car/hyundai/tests/test_ev9_pending_startup.py::TestEV9NormalSelection", "opendbc_repo/opendbc/car/hyundai/tests/test_batched_ready_observations.py", "openpilot/starpilot/aol/tests/test_canfd_location_native_recovery.py", "openpilot/starpilot/aol/tests/test_classic_location_stock_intent.py",