Require fresh Ioniq 6 parser receipts after ECU handoff

This commit is contained in:
firestar5683
2026-10-25 22:06:07 -05:00
parent f3bbd38e81
commit 0009df9084
5 changed files with 152 additions and 4 deletions
@@ -1,5 +1,6 @@
import threading
import tempfile
import time
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -18,6 +19,19 @@ from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
def refresh_host_parser_receipts(card):
from collections import deque
from opendbc.can.parser import MessageState
stamp = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
parsers = {}
for bus in (1, 2):
source = MessageState(address=bus, name=f"source{bus}", size=8, signals=[],
timeout_threshold=100_000_000, timestamps=deque([stamp]))
parsers[bus] = SimpleNamespace(message_states={bus: source})
card.CI.can_parsers = parsers
class FakeIoniq6Bus:
def __init__(self, *, source=True, heartbeat_source=True, heartbeat_stays_after_disable=False,
bsm_source=True, bsm_stays_after_disable=False, missing_status=None,
@@ -561,6 +575,9 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card.ioniq6_host_warmed = False
card.ioniq6_long_lost = False
card.ioniq6_keepalive = None
card.ioniq6_handoff_source_floor_ns = 1
card.CI = SimpleNamespace()
refresh_host_parser_receipts(card)
invalid = SimpleNamespace(canValid=False)
card.observe_ioniq6_long_authority([], invalid)
self.assertFalse(card.ioniq6_long_lost)
@@ -659,6 +676,7 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card.controls_update(cs, cc)
card.CI.apply.assert_not_called()
cs.canValid = True
refresh_host_parser_receipts(card)
card.observe_ioniq6_long_authority([], cs)
card.controls_update(cs, cc)
card.CI.apply.assert_called_once()
@@ -887,6 +905,12 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
update=Mock(side_effect=lambda packets: car.CarState(canValid=True, canTimeout=False)),
apply=Mock(return_value=(structs.CarControl.Actuators(), [CanData(0x1A0, b'\0' * 8, 1)])),
init=Mock())
def parsed_update(_packets):
refresh_host_parser_receipts(card_owner)
return car.CarState(canValid=True, canTimeout=False)
card_owner.CI.update.side_effect = parsed_update
card_owner.ioniq6_panda_diagnostic_ready = Mock(return_value=True)
card_owner.ioniq6_panda_matches = Mock(side_effect=(False, True, True))
packets = iter((
@@ -0,0 +1,106 @@
"""Recorded parser grace must not complete a new I6 ECU ownership warmup."""
from collections import deque
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate
from opendbc.car.hyundai.values import CAR
from openpilot.selfdrive.car.card import Car
def recorded_owner():
fp = gen_empty_fingerprint()
fp[0][0x3A5] = 24
fp[2].update({0x110: 32, 0x362: 32})
fp[1].update({0x130: 16, 0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1BA: 24, 0x1E5: 16, 0x36A: 16})
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fp, [], False, False, False)
cp = build_ioniq6_hda2_long_candidate(stock, fp)
assert cp is not None
cp.safetyConfigs[-1].safetyParam |= 0x0800
assert cp.flags == 1059329 and cp.safetyConfigs[-1].safetyParam == 0x8895
owner = Car.__new__(Car)
owner.CP, owner.CI = cp, CarInterface(cp)
owner.ioniq6_long_selected = owner.ioniq6_long_prearmed = True
owner.ioniq6_long_lost = owner.ioniq6_host_warmed = False
owner.ioniq6_keepalive = SimpleNamespace(stop=Mock())
return owner
def test_recorded_c8_actual_parser_grace_waits_for_post_handoff_sources():
fixture = json.loads((Path(__file__).parent / "testdata/ioniq6_post_handoff_warmup.json").read_text())
owner = recorded_owner()
floor = fixture["handoffCompletedNs"]
owner.ioniq6_handoff_source_floor_ns = floor
now = [0]
history = []
with patch("time.clock_gettime_ns", side_effect=lambda clock: now[0]):
for packet in fixture["packets"]:
ns = packet["ns"]
if fixture["lastCardSourceBeforeTransactionNs"] < ns <= floor:
continue # The real UDS transaction consumed this Card cursor interval.
now[0] = ns
frames = [(a, bytes.fromhex(data), bus) for a, data, bus in packet["frames"]]
state = owner.CI.update([(ns, frames)])
if ns <= floor:
continue
parser_valid = state.canValid
owner.observe_ioniq6_long_authority([(ns, frames)], state)
history.append((ns, parser_valid, owner.ioniq6_host_warmed, owner.ioniq6_long_lost))
assert history[0][1] is True, "Recorded parser initially tolerates stale sources"
assert history[0][2] is False, "STALE_PARSER_GRACE_MUST_NOT_FINISH_HANDOFF_WARMUP"
assert any(not row[1] for row in history), "Real parser grace eventually expires"
assert history[-1][1:3] == (True, True)
assert not any(row[3] for row in history)
owner.ioniq6_keepalive.stop.assert_not_called()
# A genuinely invalid input after completed warmup retains the old refusal.
invalid = SimpleNamespace(canValid=False)
owner.observe_ioniq6_long_authority([], invalid)
assert owner.ioniq6_long_lost and not invalid.canValid
owner.ioniq6_keepalive.stop.assert_called_once()
@pytest.mark.parametrize("fault", ("no_floor", "empty_inventory", "pre_handoff", "future", "expired", "bad_counter", "missing"))
def test_warmup_proof_rejects_missing_or_invalid_source_receipts(fault):
owner = recorded_owner()
owner.ioniq6_handoff_source_floor_ns = 1_000_000_000
now = 1_100_000_000
required = [source for parser in owner.CI.can_parsers.values()
for source in parser.message_states.values() if not source.ignore_alive]
assert len(required) >= 6
for source in required:
source.timestamps = deque([now])
source = required[-1]
if fault == "no_floor":
owner.ioniq6_handoff_source_floor_ns = 0
elif fault == "empty_inventory":
owner.CI.can_parsers = {}
elif fault == "pre_handoff":
source.timestamps = deque([owner.ioniq6_handoff_source_floor_ns])
elif fault == "future":
source.timestamps = deque([now + 1])
elif fault == "expired":
source.timeout_threshold = 10_000_000
source.timestamps = deque([now - 10_000_001])
elif fault == "bad_counter":
source.counter_fail = 5
elif fault == "missing":
source.timestamps.clear()
with patch("time.clock_gettime_ns", return_value=now):
owner.observe_ioniq6_long_authority([], SimpleNamespace(canValid=True))
assert not owner.ioniq6_host_warmed and not owner.ioniq6_long_lost
@pytest.mark.parametrize("address,bus", ((0x1A0, 1), (0x1BA, 1), (0x1E5, 1), (0x100, 0)))
def test_factory_source_return_still_revokes_during_warmup(address, bus):
owner = recorded_owner()
owner.ioniq6_handoff_source_floor_ns = 1_000_000_000
state = SimpleNamespace(canValid=True)
owner.observe_ioniq6_long_authority([(1_100_000_000, [(address, bytes(32), bus)])], state)
assert owner.ioniq6_long_lost and not state.canValid
owner.ioniq6_keepalive.stop.assert_called_once()
File diff suppressed because one or more lines are too long
+20 -3
View File
@@ -228,6 +228,7 @@ class Car:
self.ioniq6_last_preflight_at = 0.0
self.ioniq6_panda_armed = False
self.ioniq6_host_warmed = False
self.ioniq6_handoff_source_floor_ns = 0
self.ioniq6_long_lost = False
self.ioniq6_keepalive = None
self.ioniq6_restore_callbacks = None
@@ -1243,6 +1244,20 @@ class Car:
}
self.pm.send('slcCruiseEvent', event)
def ioniq6_post_handoff_sources_current(self):
"""A parser's invalid grace is not proof of post-transaction warmup."""
from opendbc.can.parser import MAX_BAD_COUNTER
floor = getattr(self, 'ioniq6_handoff_source_floor_ns', 0)
if floor <= 0:
return False
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
sources = [source for parser in self.CI.can_parsers.values()
for source in parser.message_states.values() if not source.ignore_alive]
return bool(sources) and all(
source.timestamps and floor < source.timestamps[-1] <= now and
now - source.timestamps[-1] <= source.timeout_threshold and source.counter_fail < MAX_BAD_COUNTER
for source in sources)
def observe_ioniq6_long_authority(self, can_list, CS):
if getattr(self, 'ioniq6_long_selected', False) and self.ioniq6_long_lost:
CS.canValid = False
@@ -1252,10 +1267,11 @@ class Car:
if any((src == 1 and address in (0x1A0, 0x1BA, 0x1E5)) or (src == 0 and address == 0x100)
for _, packet in can_list for address, _, src in packet):
self.ioniq6_long_lost = True
if CS.canValid:
if self.ioniq6_host_warmed:
if not CS.canValid:
self.ioniq6_long_lost = True
elif CS.canValid and self.ioniq6_post_handoff_sources_current():
self.ioniq6_host_warmed = True
elif self.ioniq6_host_warmed:
self.ioniq6_long_lost = True
if self.ioniq6_long_lost:
CS.canValid = False
if self.ioniq6_keepalive is not None:
@@ -1629,6 +1645,7 @@ class Car:
# The transaction consumed Card's CAN cursor. Rewarm the host parser
# from post-handoff independent inputs before its first controller TX.
self.ioniq6_host_warmed = False
self.ioniq6_handoff_source_floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
self.ioniq6_restore_callbacks = self.can_callbacks
elif self.vehicle_startup.owner is None:
self.CI.init(self.CP, *self.can_callbacks)
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "97d90d29dbe2b0e55aada70d8f7ad969078898f7a390d670ffa65bee1e4d8c0a", "schema_version": 1}
{"protected_inventory_sha256": "d97d73289304727972ae534f32902cf5d207ea61af92426673df8a2fd4b6ed60", "schema_version": 1}