Defer torque EV ECU disable until inactive controls are ready

This commit is contained in:
firestar5683
2026-10-24 21:34:54 -05:00
parent 0427ba9fbf
commit d6b8ade37f
5 changed files with 273 additions and 7 deletions
@@ -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
@@ -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):
+13 -4
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "84f0eb69d2d9891e2d30b08fc5e1cc99d541250394ad36b378d896787b30ac4d", "schema_version": 1}
{"protected_inventory_sha256": "e34dc5ce97408242abb88bb2622d2e6818238101b65cbc8a64e640118694c8a2", "schema_version": 1}
+2
View File
@@ -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",