"""Timestamped synthetic CAN/UDS through the actual prepublication caller.""" import time from contextlib import nullcontext import unittest from unittest.mock import patch from opendbc.can.packer import CANPacker from opendbc.car import Bus, CanData, gen_empty_fingerprint, structs from opendbc.car import car_helpers, disable_ecu as disable_module from opendbc.car.hyundai import ecu_startup from opendbc.car.hyundai.ecu_startup import Outcome, stock_copy from opendbc.car.hyundai.ev6_startup import EV6Startup, eligible from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum from opendbc.car.hyundai.interface import CarInterface from opendbc.car.hyundai.ioniq6_handoff import TimestampedCanPacket from opendbc.car.hyundai.values import CAR, DBC from openpilot.starpilot.vehicle_startup import VehicleStartupOwner def params(alpha=True, radar=False, car=CAR.KIA_EV6): fp = gen_empty_fingerprint() fp[2][0x50] = 16 fp[1][0x1cf] = 8 if radar: if car == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN: fp[0].update(dict.fromkeys(range(0x210, 0x220), 32)) else: fp[1][0x500] = 8 fw = [structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.adas)] return CarInterface.get_params(car, fp, fw, alpha, False, False) class TestEV6Startup(unittest.TestCase): @patch.object(time, 'CLOCK_BOOTTIME', getattr(time, 'CLOCK_BOOTTIME', time.CLOCK_MONOTONIC), create=True) def exercise(self, mode='sent', *, capture=True, admission=lambda: True, requested=True, wrong_bus=False, missing_source=None, fail_ctor=False, warm_fault=None, car=CAR.KIA_EV6, boot_ns=None): original = params(car=car) holder = VehicleStartupOwner() ci_ref = [] requests = [] self.last_requests = requests packer = CANPacker(DBC[car][Bus.pt]) counter = 0 boot_clock = [0 if boot_ns is None else boot_ns] def recv(wait_for_one=False): nonlocal counter counter += 1 if boot_ns is not None: boot_clock[0] += 10_000_000 if not ci_ref: if not capture: return [] data = bytearray(32) data[2], data[4] = counter % 256, 7 data[:2] = hkg_can_fd_checksum(0x51, None, data).to_bytes(2, 'little') frames = [CanData(0x51, bytes(data), 1 if wrong_bus else 0)] else: frames = [] for parser in ci_ref[0].can_parsers.values(): for address in parser.addresses: name = parser.dbc.addr_to_msg[address].name if name != missing_source and name != 'CRUISE_BUTTONS_ALT': frame = CanData(*packer.make_can_msg(name, 0 if warm_fault == 'wrongbus' and name == 'SCC_CONTROL' else parser.bus, {})) frames.extend([frame] * (7 if warm_fault == 'counter' and name == 'SCC_CONTROL' else 1)) stamp = 1 if ci_ref and warm_fault == 'stale' else time.clock_gettime_ns(time.CLOCK_BOOTTIME) return [TimestampedCanPacket(frames, stamp)] def query(send, can_recv, bus, addresses, commands, responses, **kwargs): command = commands[0] self.assertEqual(bus, 1) self.assertEqual(addresses, [(0x730, None)]) self.assertEqual(responses, [{b'\x10\x03': b'\x50\x03', b'\x28\x83\x01': b'', b'\x28\x00\x01': b'\x68\x00', b'\x10\x01': b'\x50\x01'}[command]]) requests.append(command) class Query: def get_data(self, timeout): send([CanData(0x730, bytes((len(command),))+command+bytes(7-len(command)), 1)]) if command == b'\x28\x83\x01' and mode not in ('sent', 'sent_restore'): raise OSError('simulated partial send') if command == b'\x28\x00\x01' and mode not in ('restored', 'sent_restore'): return {} # Suppressed-positive communication control has no reply. An empty # payload inside a reply dictionary is malformed, not silence. return {} if command == b'\x28\x83\x01' else {(0x730, None): b''} return Query() admission_results = [] def admit(): result = admission() admission_results.append(result) return result def hook(cp, candidate, fingerprints, firmware): return holder.prepare(cp, CarInterface, (recv, lambda frames: None), requested=requested, admission=admit) fp = gen_empty_fingerprint() fp[2][0x50], fp[1][0x1cf] = 16, 8 fw = [structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.adas)] fingerprint = (car, fp, '0'*17, fw, original.fingerprintSource, True) clock_context = (patch.object(time, 'clock_gettime_ns', side_effect=lambda clock: boot_clock[0]) if boot_ns is not None else nullcontext()) with clock_context, patch.object(car_helpers, 'fingerprint', return_value=fingerprint), \ patch.object(disable_module, 'IsoTpParallelQuery', side_effect=query), \ patch.object(ecu_startup, 'IsoTpParallelQuery', side_effect=query): if fail_ctor: try: with patch.object(CarInterface, 'CarController', side_effect=RuntimeError('constructor failed')): car_helpers.get_car(recv, lambda frames: None, lambda *args: None, True, False, pre_create_hook=hook) finally: holder.close() ci = car_helpers.get_car(recv, lambda frames: None, lambda *args: None, True, False, pre_create_hook=hook) ci_ref.append(ci) holder.configure(ci) holder.seal_publication() self.addCleanup(holder.close) if (capture and requested and mode in ('sent', 'sent_restore') and not warm_fault and not wrong_bus and admission_results and admission_results[0]): self.assertIs(holder.owner.outcome, Outcome.SENT_UNCONFIRMED) self.assertTrue(ci.CP.openpilotLongitudinalControl) self.assertEqual(ci.CP.safetyConfigs[-1].safetyParam, 0x2015 if car == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else 0x15) return ci, holder, requests def test_sent_prepublication_binds_immutable_template_and_suppressed_protocol(self): ci, holder, requests = self.exercise() self.assertIs(holder.owner.outcome, Outcome.SENT_UNCONFIRMED) self.assertIs(ci.CC.adrv_template, holder.owner.template) self.assertTrue(ci.CP.openpilotLongitudinalControl) self.assertIn(b'\x28\x83\x01', requests) self.assertNotIn(b'\x28\x03\x01', requests) ci.CP.safetyConfigs[-1].safetyParam ^= 4 with self.assertRaisesRegex(RuntimeError, 'prepared'): holder.seal_publication() def test_missing_capture_never_requests_uds_and_partial_restore_warms_actual_stock(self): ci, holder, requests = self.exercise(capture=False) self.assertEqual(requests, []) self.assertIs(holder.owner.outcome, Outcome.STOCK_UNTOUCHED) self.assertFalse(ci.CP.openpilotLongitudinalControl) ci, holder, requests = self.exercise('restored') self.assertIs(holder.owner.outcome, Outcome.STOCK_RESTORED) self.assertIn(b'\x28\x00\x01', requests) self.assertTrue(holder.owner.ready) self.assertIsNone(ci.CC.adrv_template) def test_partial_failure_without_restore_admission_cannot_publish(self): admitted = iter((True, False)) with self.assertRaisesRegex(RuntimeError, 'restoration unverified'): self.exercise('restored', admission=lambda: next(admitted)) with self.assertRaisesRegex(RuntimeError, 'restoration unverified'): self.exercise('uncertain') def test_stock_copy_exact_and_button_pause_policy_and_explicit_owner_guard(self): for radar in (False, True): self.assertEqual(stock_copy(params(radar=radar)).to_dict(), params(False, radar).to_dict()) ci = CarInterface(params()) buttons = ci.can_parsers[Bus.pt].message_states[0x1cf] self.assertGreaterEqual(buttons.timeout_threshold, 500_000_000) self.assertNotIn(0x1a0, ci.can_parsers[Bus.pt].addresses) stock = CarInterface(params(False)) self.assertIn(0x1a0, stock.can_parsers[Bus.pt].addresses) with self.assertRaisesRegex(RuntimeError, 'matching prepared'): VehicleStartupOwner().configure(ci) self.assertIsNone(CarInterface.startup_owner(params(), (list, lambda frames: None), requested=False)) sibling = params() sibling.carFingerprint = CAR.HYUNDAI_IONIQ_6 self.assertFalse(eligible(sibling)) def test_missing_stock_source_cannot_publish_and_buttons_can_pause(self): with self.assertRaisesRegex(RuntimeError, 'stock CANFD'): self.exercise('restored', missing_source='SCC_CONTROL') ci, holder, _ = self.exercise('restored') parser = ci.can_parsers[Bus.pt] # Freeze the real last button receipt while refreshing all other sources. packer = CANPacker(DBC[CAR.KIA_EV6][Bus.pt]) stamp = max(source.timestamps[-1] for source in parser.message_states.values() if source.timestamps) for i in range(1, 51): frames = [] for source_parser in ci.can_parsers.values(): for address in source_parser.addresses: name = source_parser.dbc.addr_to_msg[address].name if name not in ('CRUISE_BUTTONS', 'CRUISE_BUTTONS_ALT'): frames.append(CanData(*packer.make_can_msg(name, source_parser.bus, {}))) ci.update([(stamp+i*10_000_000, frames)]) self.assertTrue(ci.CS.out.canValid) self.assertTrue(holder.owner._sources(ci, stamp+500_000_000, floor=holder.owner.floor_ns)) def test_capture_rejects_wrong_bus_crc_repeats_and_old_producers(self): for bad in ('bus', 'crc', 'repeat', 'stale'): with self.subTest(bad=bad): tick = [1_000_000_000] counter = [0] def recv(tick=tick, counter=counter, bad=bad): tick[0] += 100_000_000 counter[0] += 1 data = bytearray(32) data[2] = 7 if bad == 'repeat' else counter[0] data[4] = 1 data[:2] = hkg_can_fd_checksum(0x51, None, data).to_bytes(2, 'little') if bad == 'crc': data[0] ^= 1 return [TimestampedCanPacket([CanData(0x51, bytes(data), 1 if bad == 'bus' else 0)], 1 if bad == 'stale' else tick[0])] owner = EV6Startup(params(), (recv, lambda frames: self.fail('capture must not transmit'))) with patch.object(time, 'CLOCK_BOOTTIME', getattr(time, 'CLOCK_BOOTTIME', time.CLOCK_MONOTONIC), create=True), \ patch.object(time, 'clock_gettime_ns', side_effect=lambda clock, tick=tick: tick[0]), \ patch.object(time, 'monotonic', side_effect=lambda tick=tick: tick[0]/1e9), patch.object(time, 'sleep'): self.assertIsNone(owner._capture()) def test_unpublished_actual_constructor_failure_restores_transaction(self): with self.assertRaisesRegex(RuntimeError, 'constructor failed'): self.exercise('sent_restore', fail_ctor=True) self.assertIn(b'\x28\x83\x01', self.last_requests) self.assertIn(b'\x28\x00\x01', self.last_requests) def test_wrong_bus_stale_and_counter_loss_stock_sources_cannot_publish(self): for fault in ('wrongbus', 'stale', 'counter'): with self.subTest(fault=fault), self.assertRaisesRegex(RuntimeError, 'stock CANFD'): self.exercise('restored', warm_fault=fault)