mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 01:23:58 +08:00
Defer torque EV ECU disable until inactive controls are ready
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "84f0eb69d2d9891e2d30b08fc5e1cc99d541250394ad36b378d896787b30ac4d", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "e34dc5ce97408242abb88bb2622d2e6818238101b65cbc8a64e640118694c8a2", "schema_version": 1}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user