mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-14 00:13:42 +08:00
Runtime AOL
Register vehicle AOL adapters and runtime dispatch after the vehicle factories and native contracts exist. Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Actual classic-Bosch Honda CP/controller frames against the AOL safety hooks."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from opendbc.car import gen_empty_fingerprint, structs
|
||||
from opendbc.car.honda.carcontroller import CarController
|
||||
from opendbc.car.honda.interface import CarInterface
|
||||
from opendbc.car.honda.values import CAR, CarControllerParams, HondaSafetyFlags
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from opendbc.safety.tests.test_honda import Btn
|
||||
from openpilot.starpilot.car.honda.aol import CLASSIC_BOSCH_AOL_CARS, qualified_honda
|
||||
from openpilot.starpilot.aol.runtime import decide_axes
|
||||
|
||||
|
||||
def _frames(candidate, cp, *, lat: bool, long: bool):
|
||||
cc = structs.CarControl()
|
||||
cc.enabled = lat or long
|
||||
cc.latActive = lat
|
||||
cc.longActive = long
|
||||
cc.actuators.torque = 0.01
|
||||
cc.actuators.accel = 1.0
|
||||
state = SimpleNamespace(out=structs.CarState(), v_cruise_factor=1.0, is_metric=True, acc_hud={}, lkas_hud={})
|
||||
state.out.vEgo = 20.0
|
||||
state.out.cruiseState.available = True
|
||||
controller = CarController(candidate.config.dbc_dict, cp)
|
||||
_, sent = controller.update(cc.as_reader(), state, 0)
|
||||
return {msg[0]: msg for msg in sent if isinstance(msg, tuple)}
|
||||
|
||||
|
||||
def _packet(frame):
|
||||
address, data, bus = frame
|
||||
return libsafety_py.make_CANPacket(address, bus, data)
|
||||
|
||||
|
||||
class TestHondaAolFamily(unittest.TestCase):
|
||||
def test_real_controller_frames_obey_native_four_axis_masks(self):
|
||||
if libsafety_py.libsafety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) != 0:
|
||||
self.skipTest('Independent AOL axes require ALLOW_DEBUG firmware')
|
||||
from opendbc.safety.tests.test_honda import TestHondaBoschLongSafety
|
||||
gas_lookup = list(CarControllerParams.BOSCH_GAS_LOOKUP_V)
|
||||
try:
|
||||
for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str):
|
||||
for detected_alt in ((False, True) if candidate == CAR.HONDA_ACCORD else (False,)):
|
||||
with self.subTest(candidate=candidate, detected_alt=detected_alt):
|
||||
CarControllerParams.BOSCH_GAS_LOOKUP_V = list(gas_lookup)
|
||||
fingerprint = gen_empty_fingerprint()
|
||||
if detected_alt:
|
||||
fingerprint[1][0x1BE] = 3
|
||||
cp = CarInterface.get_params(candidate, fingerprint, [], True, False, False)
|
||||
self.assertTrue(qualified_honda(cp))
|
||||
self.assertEqual(bool(cp.safetyConfigs[0].safetyParam & HondaSafetyFlags.ALT_BRAKE),
|
||||
candidate in (CAR.HONDA_CRV_5G, CAR.ACURA_RDX_3G) or detected_alt)
|
||||
|
||||
for mode, lat, long in (("off", False, False), ("lateralOnly", True, False),
|
||||
("longitudinalOnly", False, True), ("combined", True, True)):
|
||||
with self.subTest(mode=mode):
|
||||
native = SimpleNamespace(requestedLateral=lat, requestedLongitudinal=long,
|
||||
lateralAllowed=lat, longitudinalAllowed=long)
|
||||
state = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive, vEgo=20.0)
|
||||
intent = SimpleNamespace(allowedLatch=lat, pauseLateral=not lat, pauseLongitudinal=not long)
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=long, intent=intent,
|
||||
native=native, car_state=state, initialized=True, model_ready=True,
|
||||
no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.0)
|
||||
self.assertEqual(decision.mode, mode)
|
||||
frames = _frames(candidate, cp, lat=decision.lateral_active, long=decision.longitudinal_active)
|
||||
self.assertEqual(frames[0xE4][2], 1)
|
||||
self.assertEqual(frames[0x1DF][2], 1)
|
||||
|
||||
fixture = TestHondaBoschLongSafety('test_diagnostics')
|
||||
fixture.setUp()
|
||||
safety = fixture.safety
|
||||
safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch,
|
||||
int(cp.safetyConfigs[0].safetyParam) | int(HondaSafetyFlags.AOL_BOSCH_LONG))
|
||||
safety.init_tests()
|
||||
safety.set_timer(1_000_000)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
fixture._rx(fixture._acc_state_msg(True))
|
||||
fixture._rx(fixture._button_msg(Btn.NONE))
|
||||
fixture._rx(fixture._speed_msg(20))
|
||||
fixture._rx(fixture._powertrain_data_msg())
|
||||
if cp.safetyConfigs[0].safetyParam & HondaSafetyFlags.ALT_BRAKE:
|
||||
fixture._rx(fixture._alt_brake_msg(0))
|
||||
if long:
|
||||
fixture._rx(fixture._button_msg(Btn.SET))
|
||||
fixture._rx(fixture._button_msg(Btn.NONE))
|
||||
self.assertTrue(safety.get_controls_allowed())
|
||||
safety.aol_set_host_request(int(lat) | (int(long) << 1))
|
||||
self.assertEqual(safety.aol_get_permission_mask(), int(lat) | (int(long) << 1))
|
||||
steer = _packet(frames[0xE4])
|
||||
acc = _packet(frames[0x1DF])
|
||||
self.assertTrue(safety.safety_tx_hook(steer))
|
||||
self.assertTrue(safety.safety_tx_hook(acc))
|
||||
|
||||
active_steer = _frames(candidate, cp, lat=True, long=False)[0xE4]
|
||||
active_acc = _frames(candidate, cp, lat=False, long=True)[0x1DF]
|
||||
if not lat:
|
||||
self.assertFalse(safety.safety_tx_hook(_packet(active_steer)))
|
||||
if not long:
|
||||
self.assertFalse(safety.safety_tx_hook(_packet(active_acc)))
|
||||
finally:
|
||||
CarControllerParams.BOSCH_GAS_LOOKUP_V = gas_lookup
|
||||
|
||||
def test_native_main_brake_source_and_health_for_both_safety_params(self):
|
||||
if libsafety_py.libsafety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) != 0:
|
||||
self.skipTest('Independent AOL axes require ALLOW_DEBUG firmware')
|
||||
from opendbc.safety.tests.test_honda import TestHondaBoschLongSafety
|
||||
|
||||
for alt_brake in (False, True):
|
||||
with self.subTest(alt_brake=alt_brake):
|
||||
fixture = TestHondaBoschLongSafety('test_diagnostics')
|
||||
fixture.setUp()
|
||||
safety = fixture.safety
|
||||
safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch,
|
||||
int(HondaSafetyFlags.BOSCH_LONG | HondaSafetyFlags.AOL_BOSCH_LONG |
|
||||
(HondaSafetyFlags.ALT_BRAKE if alt_brake else 0)))
|
||||
safety.init_tests()
|
||||
safety.set_timer(1_000_000)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
fixture._rx(fixture._acc_state_msg(True))
|
||||
fixture._rx(fixture._button_msg(Btn.NONE))
|
||||
fixture._rx(fixture._speed_msg(20))
|
||||
fixture._rx(fixture._powertrain_data_msg())
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0 if alt_brake else 1)
|
||||
if alt_brake:
|
||||
wrong_bus = fixture._alt_brake_msg(0)
|
||||
wrong_bus[0].bus = 0
|
||||
fixture._rx(wrong_bus)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
bad_checksum = fixture._alt_brake_msg(0)
|
||||
bad_checksum[0].data[2] ^= 1
|
||||
self.assertFalse(fixture._rx(bad_checksum))
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch,
|
||||
int(HondaSafetyFlags.BOSCH_LONG | HondaSafetyFlags.AOL_BOSCH_LONG |
|
||||
HondaSafetyFlags.ALT_BRAKE))
|
||||
safety.init_tests()
|
||||
safety.set_timer(1_000_000)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
fixture._rx(fixture._acc_state_msg(True))
|
||||
fixture._rx(fixture._button_msg(Btn.NONE))
|
||||
fixture._rx(fixture._speed_msg(20))
|
||||
fixture._rx(fixture._powertrain_data_msg())
|
||||
fixture._rx(fixture._alt_brake_msg(0))
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
fixture._rx(fixture._alt_brake_msg(1))
|
||||
self.assertTrue(safety.get_brake_pressed_prev())
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1) # native lateral permission is separate from brake-paused host intent
|
||||
fixture._rx(fixture._alt_brake_msg(0))
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
safety.set_timer(1_300_001)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
safety.set_timer(1_400_000)
|
||||
fixture._rx(fixture._acc_state_msg(True))
|
||||
fixture._rx(fixture._button_msg(Btn.NONE))
|
||||
fixture._rx(fixture._speed_msg(20))
|
||||
fixture._rx(fixture._powertrain_data_msg())
|
||||
if alt_brake:
|
||||
fixture._rx(fixture._alt_brake_msg(0))
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
fixture._rx(fixture._button_msg(Btn.CANCEL))
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
safety.aol_set_host_request(1)
|
||||
safety.set_aol_test_heartbeat(False)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
fixture._rx(fixture._acc_state_msg(False))
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
|
||||
|
||||
class TestHondaAolFamilyRelease(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.gas_lookup = list(CarControllerParams.BOSCH_GAS_LOOKUP_V)
|
||||
self.addCleanup(setattr, CarControllerParams, 'BOSCH_GAS_LOOKUP_V', self.gas_lookup)
|
||||
|
||||
def test_release_firmware_never_advertises_family_axis_permission(self):
|
||||
safety = libsafety_py.libsafety
|
||||
if safety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0:
|
||||
self.skipTest('Release-only denial contract')
|
||||
for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str):
|
||||
with self.subTest(candidate=candidate):
|
||||
CarControllerParams.BOSCH_GAS_LOOKUP_V = list(self.gas_lookup)
|
||||
cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], True, False, False)
|
||||
self.assertTrue(qualified_honda(cp))
|
||||
safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch,
|
||||
int(cp.safetyConfigs[0].safetyParam) | int(HondaSafetyFlags.AOL_BOSCH_LONG))
|
||||
safety.init_tests()
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
safety.aol_set_host_request(3)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
frames = _frames(candidate, cp, lat=True, long=True)
|
||||
self.assertFalse(safety.safety_tx_hook(_packet(frames[0xE4])))
|
||||
self.assertFalse(safety.safety_tx_hook(_packet(frames[0x1DF])))
|
||||
@@ -0,0 +1,34 @@
|
||||
import unittest
|
||||
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.gm.tests.test_bolt_cc import fixture, feed, control
|
||||
from opendbc.car.gm.values import CAR
|
||||
from openpilot.selfdrive.car.car_events import CarEvents, EventName
|
||||
from openpilot.selfdrive.selfdrived.events import EVENTS, ET
|
||||
|
||||
|
||||
SUPPORTED_NON_ACC = (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023)
|
||||
|
||||
|
||||
class TestBoltCruiseModeEvent(unittest.TestCase):
|
||||
def test_actual_bolt_ci_state_does_not_block_engagement_for_wrong_cruise_mode(self):
|
||||
for identity in SUPPORTED_NON_ACC:
|
||||
for removed in (False, True):
|
||||
cp, ci, packer = fixture(identity, removed=removed)
|
||||
state, _ = feed(ci, packer, 1_000_000_000, active=True, speed=20)
|
||||
self.assertTrue(state.canValid)
|
||||
self.assertFalse(cp.pcmCruise)
|
||||
self.assertTrue(state.cruiseState.enabled)
|
||||
events = CarEvents(cp).update(state.as_reader(), structs.CarState.new_message().as_reader(), control().as_reader())
|
||||
self.assertNotIn(EventName.wrongCruiseMode, events.names, (identity, removed))
|
||||
self.assertFalse(state.cruiseState.nonAdaptive)
|
||||
|
||||
def test_wrong_mode_still_produces_stock_owner_no_entry_and_user_disable(self):
|
||||
cp, _, _ = fixture(CAR.CHEVROLET_BOLT_EUV)
|
||||
self.assertTrue(cp.pcmCruise)
|
||||
state = structs.CarState.new_message()
|
||||
state.cruiseState.nonAdaptive = True
|
||||
events = CarEvents(cp).update(state.as_reader(), structs.CarState.new_message().as_reader(), control().as_reader())
|
||||
self.assertIn(EventName.wrongCruiseMode, events.names)
|
||||
self.assertIn(ET.NO_ENTRY, EVENTS[EventName.wrongCruiseMode])
|
||||
self.assertIn(ET.USER_DISABLE, EVENTS[EventName.wrongCruiseMode])
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Saved cruise intervals alter only Card's non-PCM button increments."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS, IMPERIAL_INCREMENT, SlcPendingConfirmation, VCruiseHelper
|
||||
from openpilot.starpilot.longitudinal.cruise_intervals import CruiseIntervals, read_cruise_intervals
|
||||
|
||||
|
||||
Button = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def state(button=None, pressed=False, *, standstill=False):
|
||||
events = [] if button is None else [car.CarState.ButtonEvent(type=button, pressed=pressed)]
|
||||
return car.CarState(cruiseState={"available": True, "standstill": standstill}, buttonEvents=events)
|
||||
|
||||
|
||||
class TestCruiseIntervals(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.params = Params(temp.name)
|
||||
|
||||
def test_saved_children_ignore_retired_parent_and_keep_pcm_gate(self):
|
||||
self.params.put("CustomCruise", 5.0, block=True)
|
||||
self.params.put("CustomCruiseLong", 1.0, block=True)
|
||||
self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1))
|
||||
self.params.put_bool("QOLLongitudinal", True, block=True)
|
||||
self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1))
|
||||
self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=True), CruiseIntervals())
|
||||
self.params.put_bool("QOLLongitudinal", False, block=True)
|
||||
self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1))
|
||||
|
||||
def test_invalid_children_fall_back_without_enabling_other_features(self):
|
||||
self.params.put_bool("QOLLongitudinal", True, block=True)
|
||||
Path(self.params.get_param_path("CustomCruise")).write_bytes(b"nan")
|
||||
Path(self.params.get_param_path("CustomCruiseLong")).write_bytes(b"200")
|
||||
self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals())
|
||||
|
||||
def helper(self, *, metric=True, custom=True, initial=67.0, pcm=False):
|
||||
helper = VCruiseHelper(car.CarParams(pcmCruise=pcm))
|
||||
helper.v_cruise_kph = initial
|
||||
if custom:
|
||||
helper.intervals = CruiseIntervals(5, 1)
|
||||
return helper
|
||||
|
||||
@staticmethod
|
||||
def step(helper, button=None, pressed=False, *, metric=True, pending=None, standstill=False):
|
||||
helper.update_v_cruise(state(button, pressed, standstill=standstill), True, metric, pending)
|
||||
|
||||
def test_metric_short_and_held_accel_and_decel(self):
|
||||
for button, short_target, held_target in ((Button.accelCruise, 70, 71), (Button.decelCruise, 65, 64)):
|
||||
with self.subTest(button=button):
|
||||
helper = self.helper()
|
||||
self.step(helper, button, True)
|
||||
self.step(helper, button, False)
|
||||
self.assertEqual(helper.v_cruise_kph, short_target)
|
||||
self.assertEqual(helper.slc_cruise_change[2:], ("accel" if button == Button.accelCruise else "decel", False))
|
||||
self.step(helper, button, True)
|
||||
for _ in range(CRUISE_LONG_PRESS - 1):
|
||||
self.step(helper)
|
||||
self.step(helper)
|
||||
self.assertEqual(helper.v_cruise_kph, held_target)
|
||||
self.step(helper, button, False)
|
||||
self.assertEqual(helper.v_cruise_kph, held_target)
|
||||
self.assertIsNone(helper.slc_cruise_change)
|
||||
|
||||
def test_imperial_display_unit_increments(self):
|
||||
helper = self.helper(initial=50 * IMPERIAL_INCREMENT)
|
||||
self.step(helper, Button.accelCruise, True, metric=False)
|
||||
self.step(helper, Button.accelCruise, False, metric=False)
|
||||
self.assertAlmostEqual(helper.v_cruise_kph, 55 * IMPERIAL_INCREMENT)
|
||||
self.step(helper, Button.accelCruise, True, metric=False)
|
||||
for _ in range(CRUISE_LONG_PRESS):
|
||||
self.step(helper, metric=False)
|
||||
self.assertAlmostEqual(helper.v_cruise_kph, 56 * IMPERIAL_INCREMENT)
|
||||
|
||||
def test_default_pcm_standstill_and_slc_ownership(self):
|
||||
default = self.helper(custom=False, initial=60)
|
||||
self.step(default, Button.accelCruise, True)
|
||||
self.step(default, Button.accelCruise, False)
|
||||
self.assertEqual(default.v_cruise_kph, 61)
|
||||
|
||||
pcm = self.helper(pcm=True, initial=60)
|
||||
self.step(pcm, Button.accelCruise, True)
|
||||
self.step(pcm, Button.accelCruise, False)
|
||||
self.assertEqual(pcm.v_cruise_kph, 255)
|
||||
for pressed in (True, False):
|
||||
pcm_state = state(Button.accelCruise, pressed)
|
||||
pcm_state.cruiseState.speed = 20.0
|
||||
pcm_state.cruiseState.speedCluster = 21.0
|
||||
pcm.update_v_cruise(pcm_state, True, True)
|
||||
self.assertEqual(pcm.v_cruise_kph, 72.0)
|
||||
self.assertAlmostEqual(pcm.v_cruise_cluster_kph, 75.6)
|
||||
|
||||
standstill = self.helper(initial=60)
|
||||
self.step(standstill, Button.accelCruise, True, standstill=True)
|
||||
self.step(standstill, Button.accelCruise, False)
|
||||
self.assertEqual(standstill.v_cruise_kph, 60)
|
||||
|
||||
slc = self.helper(initial=60)
|
||||
pending = SlcPendingConfirmation("s", 1, 1)
|
||||
self.step(slc, Button.accelCruise, True, pending=pending)
|
||||
self.step(slc, Button.accelCruise, False)
|
||||
self.assertEqual(slc.v_cruise_kph, 60)
|
||||
self.assertTrue(slc.slc_released_suppressed)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Physical distance gestures reserve only their mapped custom action."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.car.card import Car
|
||||
from openpilot.selfdrive.car.cruise import VCruiseHelper, CRUISE_LONG_PRESS
|
||||
from openpilot.selfdrive.selfdrived.events import EventName, Events
|
||||
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent, read_settings as read_aol_settings
|
||||
from openpilot.selfdrive.car.tests.test_hyundai_aol import candidate as ioniq6_candidate
|
||||
from openpilot.starpilot.conditional_mode.manual import ButtonTracker
|
||||
|
||||
|
||||
ButtonType = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def state(*events):
|
||||
return car.CarState(canValid=True, buttonEvents=[car.CarState.ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)
|
||||
for pressed in events])
|
||||
|
||||
|
||||
class Publisher:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def send(self, service, message):
|
||||
self.messages.append((service, message.to_bytes()))
|
||||
|
||||
|
||||
class TestDistancePersonalityClaim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.params = Params(temp.name)
|
||||
_, long_cp = ioniq6_candidate(False)
|
||||
self.cp = long_cp
|
||||
self.publisher = Publisher()
|
||||
self.card = Car.__new__(Car)
|
||||
self.enterContext(patch.multiple(self.card, create=True, params=self.params, CP=self.cp,
|
||||
distance_personality_tracker=ButtonTracker(), aol_card_intent=None,
|
||||
sm=NS(frame=1, all_checks=lambda _: True), pm=self.publisher,
|
||||
slc_replay=False, curve_replay=False, conditional_replay=False, aol_replay=False,
|
||||
last_actuators_output=car.CarControl.Actuators(), can_rcv_cum_timeout_counter=0,
|
||||
rk=NS(remaining=0.0), v_cruise_helper=VCruiseHelper(self.cp), slc_receipts=[]))
|
||||
Car.observe_distance_personality(self.card, state(), 1)
|
||||
|
||||
def observe(self, physical, tick):
|
||||
Car.observe_distance_personality(self.card, physical, tick)
|
||||
return physical
|
||||
|
||||
def published(self, physical):
|
||||
self.publisher.messages.clear()
|
||||
Car.state_publish(self.card, physical, None)
|
||||
return next(messaging.log_from_bytes(payload).carState for service, payload in self.publisher.messages
|
||||
if service == 'carState')
|
||||
|
||||
def test_custom_short_and_conditional_code_reserve_release(self):
|
||||
for code in (3, 4, 5):
|
||||
with self.subTest(code=code):
|
||||
self.params.put('DistanceButtonControl', code, block=True)
|
||||
self.observe(state(True), 10)
|
||||
release = self.observe(state(False), 11)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 0)
|
||||
self.assertEqual(len(release.buttonEvents), 1)
|
||||
self.params.put('DistanceButtonControl', 0, block=True)
|
||||
self.observe(state(True), 12)
|
||||
release = self.observe(state(False), 13)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 1)
|
||||
|
||||
def test_long_mapping_preserves_short_fallback_and_claims_later_release(self):
|
||||
self.params.put('LongDistanceButtonControl', 3, block=True)
|
||||
self.observe(state(True), 10)
|
||||
self.assertEqual(len(self.published(self.observe(state(False), 11)).buttonEvents), 1)
|
||||
self.observe(state(True), 20)
|
||||
for tick in range(CRUISE_LONG_PRESS - 1):
|
||||
self.observe(state(), 21 + tick)
|
||||
self.params.put('LongDistanceButtonControl', 0, block=True)
|
||||
release = self.observe(state(False), 100)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 0)
|
||||
self.observe(state(True), 101)
|
||||
self.assertEqual(len(self.published(self.observe(state(False), 102)).buttonEvents), 1)
|
||||
|
||||
def test_very_long_and_unqualified_keep_unmapped_fallback(self):
|
||||
self.params.put('VeryLongDistanceButtonControl', 5, block=True)
|
||||
self.observe(state(True), 10)
|
||||
for tick in range(CRUISE_LONG_PRESS * 5 - 1):
|
||||
self.observe(state(), 11 + tick)
|
||||
self.assertEqual(len(self.published(self.observe(state(False), 300)).buttonEvents), 0)
|
||||
stock, _ = ioniq6_candidate(False)
|
||||
self.card.CP = stock
|
||||
self.card.distance_personality_tracker = None
|
||||
self.assertEqual(len(self.published(state(False)).buttonEvents), 1)
|
||||
|
||||
def test_base_ioniq_long_without_aol_profile_reserves_conditional_gesture(self):
|
||||
self.card.CP.safetyConfigs[0].safetyParam &= ~0x800
|
||||
self.params.put('DistanceButtonControl', 5, block=True)
|
||||
self.observe(state(True), 10)
|
||||
self.assertEqual(len(self.published(self.observe(state(False), 11)).buttonEvents), 0)
|
||||
|
||||
def test_startup_held_and_invalid_can_do_not_inherit_a_claim(self):
|
||||
self.params.put('DistanceButtonControl', 3, block=True)
|
||||
self.params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True)
|
||||
self.card.distance_personality_tracker = ButtonTracker()
|
||||
pressed = state(True)
|
||||
self.card.aol_card_intent.update(pressed)
|
||||
self.observe(pressed, 10)
|
||||
release = state(False)
|
||||
self.card.aol_card_intent.update(release)
|
||||
self.observe(release, 11)
|
||||
self.assertFalse(self.card.aol_card_intent.pause_lateral)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 1)
|
||||
|
||||
self.observe(state(), 20)
|
||||
self.card.aol_card_intent.update(state())
|
||||
self.params.put('LongDistanceButtonControl', 3, block=True)
|
||||
self.observe(state(True), 21)
|
||||
for tick in range(CRUISE_LONG_PRESS - 1):
|
||||
self.observe(state(), 22 + tick)
|
||||
self.assertIsNotNone(self.card.distance_personality_tracker.distance_claim)
|
||||
self.observe(car.CarState(canValid=False), 80)
|
||||
self.assertIsNone(self.card.distance_personality_tracker.distance_claim)
|
||||
self.assertEqual(len(self.published(self.observe(state(False), 81)).buttonEvents), 1)
|
||||
self.observe(state(), 82)
|
||||
self.card.aol_card_intent.update(state())
|
||||
self.observe(state(True), 83)
|
||||
self.card.aol_card_intent.update(state(True))
|
||||
self.observe(state(False), 84)
|
||||
self.card.aol_card_intent.update(state(False))
|
||||
self.assertTrue(self.card.aol_card_intent.pause_lateral)
|
||||
|
||||
self.observe(state(True), 90)
|
||||
self.card.aol_card_intent.update(state(True))
|
||||
timeout = car.CarState(canValid=True, canTimeout=True)
|
||||
self.observe(timeout, 91)
|
||||
self.card.aol_card_intent.update(timeout)
|
||||
self.assertIsNone(self.card.distance_personality_tracker.distance_claim)
|
||||
self.observe(state(False), 92)
|
||||
self.card.aol_card_intent.update(state(False))
|
||||
self.assertTrue(self.card.aol_card_intent.pause_lateral)
|
||||
|
||||
def test_mid_press_saved_change_keeps_sampled_aol_action_reserved(self):
|
||||
self.params.put('DistanceButtonControl', 3, block=True)
|
||||
self.params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True)
|
||||
self.card.aol_card_intent.update(state())
|
||||
pressed = state(True)
|
||||
self.card.aol_card_intent.update(pressed)
|
||||
self.observe(pressed, 10)
|
||||
self.params.put('DistanceButtonControl', 0, block=True)
|
||||
release = state(False)
|
||||
self.card.aol_card_intent.update(release)
|
||||
self.observe(release, 11)
|
||||
self.assertTrue(self.card.aol_card_intent.pause_lateral)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 0)
|
||||
|
||||
def test_lkas_first_startup_interleaving_does_not_diverge_from_tracker(self):
|
||||
self.params.put('DistanceButtonControl', 3, block=True)
|
||||
self.params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True)
|
||||
self.card.distance_personality_tracker = ButtonTracker()
|
||||
lkas = car.CarState(canValid=True, buttonEvents=[car.CarState.ButtonEvent(type=ButtonType.lkas, pressed=True)])
|
||||
self.card.aol_card_intent.update(lkas)
|
||||
self.observe(lkas, 10)
|
||||
pressed = state(True)
|
||||
self.card.aol_card_intent.update(pressed)
|
||||
self.observe(pressed, 11)
|
||||
release = state(False)
|
||||
self.card.aol_card_intent.update(release)
|
||||
self.observe(release, 12)
|
||||
self.assertFalse(self.card.aol_card_intent.pause_lateral)
|
||||
self.assertEqual(len(self.published(release).buttonEvents), 1)
|
||||
|
||||
def test_selfdrived_only_cycles_for_unclaimed_serialized_release(self):
|
||||
self.params.put('LongDistanceButtonControl', 3, block=True)
|
||||
self.observe(state(True), 10)
|
||||
short = self.published(self.observe(state(False), 11))
|
||||
self.observe(state(True), 20)
|
||||
for tick in range(CRUISE_LONG_PRESS - 1):
|
||||
self.observe(state(), 21 + tick)
|
||||
claimed = self.published(self.observe(state(False), 100))
|
||||
self.assertEqual((len(short.buttonEvents), len(claimed.buttonEvents)), (1, 0))
|
||||
drive = SelfdriveD.__new__(SelfdriveD)
|
||||
drive.CP = car.CarParams(openpilotLongitudinalControl=True, notCar=True)
|
||||
drive.params = MagicMock()
|
||||
drive.events = Events()
|
||||
drive.personality = 1
|
||||
drive.initialized = True
|
||||
drive.startup_event = None
|
||||
drive.big_model_loading = False
|
||||
drive.big_model_active = False
|
||||
drive.big_model_failed = False
|
||||
drive.big_model_ready_t = 0.0
|
||||
drive.enabled = False
|
||||
drive.nostalgia_enabled = False
|
||||
drive.CS_prev = car.CarState.new_message()
|
||||
drive.dm_lockout_set = False
|
||||
drive.dm_uncertain_alerted = False
|
||||
drive.distance_traveled = 0.0
|
||||
drive.last_functional_fan_frame = 0
|
||||
drive.recalibrating_seen = False
|
||||
drive.is_ldw_enabled = False
|
||||
drive.calibrated_pose = None
|
||||
drive.excessive_actuation = False
|
||||
drive.not_running_prev = None
|
||||
drive.logged_comm_issue = None
|
||||
drive.sensor_packets = []
|
||||
drive.camera_packets = []
|
||||
drive.cruise_mismatch_counter = 0
|
||||
drive.last_steering_pressed_frame = 0
|
||||
drive.gps_location_service = 'gpsLocationExternal'
|
||||
services = ('controlsState', 'deviceState', 'driverMonitoringState', 'peripheralState',
|
||||
'extrinsicsCalibration', 'modelV2', 'managerState', 'radarState', 'longitudinalPlan',
|
||||
'deviceMotion', 'vehicleParameters', 'carControl')
|
||||
messages = {service: getattr(messaging.new_message(service), service) for service in services}
|
||||
messages['pandaStates'] = []
|
||||
class FakeMaster:
|
||||
frame = 1
|
||||
|
||||
def __init__(self):
|
||||
self.updated = {}
|
||||
self.seen = {}
|
||||
self.valid = {}
|
||||
self.alive = {}
|
||||
self.freq_ok = {}
|
||||
self.recv_frame = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return messages[key]
|
||||
|
||||
def all_checks(self, *_):
|
||||
return True
|
||||
|
||||
def all_alive(self, *_):
|
||||
return True
|
||||
|
||||
def all_freq_ok(self, *_):
|
||||
return True
|
||||
|
||||
self.enterContext(patch.multiple(drive, create=True, sm=FakeMaster(), rk=NS(lagging=False),
|
||||
car_events=NS(update=lambda *_: NS(to_msg=list))))
|
||||
for service in (*services, 'userBookmark', 'alertDebug', 'lateralManeuverPlan', 'managerState',
|
||||
'gpsLocationExternal', 'deviceMotion', 'vehicleParameters'):
|
||||
drive.sm.updated[service] = False
|
||||
drive.sm.seen[service] = False
|
||||
drive.sm.valid[service] = True
|
||||
drive.sm.alive[service] = True
|
||||
drive.sm.freq_ok[service] = True
|
||||
drive.sm.recv_frame[service] = 0
|
||||
drive.update_events(short)
|
||||
self.assertEqual(drive.personality, 0)
|
||||
self.assertIn(EventName.personalityChanged, drive.events.names)
|
||||
drive.update_events(claimed)
|
||||
self.assertEqual(drive.personality, 0)
|
||||
self.assertNotIn(EventName.personalityChanged, drive.events.names)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Honda AOL host qualification and serialized integration tests."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car import gen_empty_fingerprint
|
||||
from opendbc.car.interfaces import RadarInterfaceBase
|
||||
from opendbc.car.honda.interface import CarInterface
|
||||
from opendbc.car.honda.values import CAR, CarControllerParams, HondaFlags
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.car.card import Car
|
||||
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
|
||||
from openpilot.selfdrive.controls.controlsd import Controls
|
||||
from openpilot.starpilot.aol.intent import read_settings
|
||||
from openpilot.starpilot.aol.wire import SafetyState, decode_intent, encode_safety
|
||||
from openpilot.starpilot.car.honda.aol import CLASSIC_BOSCH_AOL_CARS, qualified_honda
|
||||
|
||||
|
||||
def car_state(*, brake=False):
|
||||
return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive,
|
||||
vEgo=20.0, brakePressed=brake)
|
||||
|
||||
|
||||
class HondaFamilyQualificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Some upstream interfaces set this shared table while constructing CP.
|
||||
self.addCleanup(setattr, CarControllerParams, 'BOSCH_GAS_LOOKUP_V', list(CarControllerParams.BOSCH_GAS_LOOKUP_V))
|
||||
|
||||
def test_exact_classic_bosch_cp_family_and_rejections(self):
|
||||
self.assertEqual(len(CLASSIC_BOSCH_AOL_CARS), 10)
|
||||
all_honda = tuple(candidate for candidate in CAR if candidate.config.flags & HondaFlags.BOSCH)
|
||||
for candidate in all_honda:
|
||||
with self.subTest(candidate=candidate):
|
||||
cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], True, False, False)
|
||||
self.assertEqual(qualified_honda(cp), candidate in CLASSIC_BOSCH_AOL_CARS)
|
||||
if candidate in CLASSIC_BOSCH_AOL_CARS:
|
||||
self.assertEqual(cp.brand, 'honda')
|
||||
self.assertTrue(cp.openpilotLongitudinalControl)
|
||||
self.assertFalse(cp.pcmCruise)
|
||||
self.assertEqual(len(cp.safetyConfigs), 1)
|
||||
self.assertIn(cp.safetyConfigs[0].safetyParam, (2, 3))
|
||||
cp.safetyConfigs[0].safetyParam |= 32
|
||||
self.assertTrue(qualified_honda(cp))
|
||||
cp.safetyConfigs[0].safetyParam |= 4
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
|
||||
for candidate in CLASSIC_BOSCH_AOL_CARS:
|
||||
with self.subTest(stock=candidate):
|
||||
cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False)
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
|
||||
cp = CarInterface.get_params(CAR.HONDA_ACCORD, gen_empty_fingerprint(), [], True, False, False)
|
||||
cp.passive = True
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.passive = False
|
||||
cp.dashcamOnly = True
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.dashcamOnly = False
|
||||
cp.brand = 'other'
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.brand = 'honda'
|
||||
cp.notCar = True
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.notCar = False
|
||||
cp.flags |= int(HondaFlags.BOSCH_ALT_RADAR)
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.flags &= ~int(HondaFlags.BOSCH_ALT_RADAR)
|
||||
cp.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.hondaNidec
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
cp.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.hondaBosch
|
||||
cp.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=2),
|
||||
car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.noOutput, safetyParam=0)]
|
||||
self.assertFalse(qualified_honda(cp))
|
||||
|
||||
def test_card_serializes_opt_in_only_for_actual_eligible_cp(self):
|
||||
class FakeHonda:
|
||||
CC = object()
|
||||
CS = object()
|
||||
|
||||
def __init__(self, candidate, alpha_long):
|
||||
self.CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], alpha_long, False, False)
|
||||
|
||||
def init(self, _cp, _recv, _send):
|
||||
pass
|
||||
|
||||
class FakeRadar(RadarInterfaceBase):
|
||||
def update(self, can_packets):
|
||||
return None
|
||||
|
||||
for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str):
|
||||
for alpha_long in (False, True):
|
||||
with self.subTest(candidate=candidate, alpha_long=alpha_long), OpenpilotPrefix(), \
|
||||
mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}):
|
||||
messaging.reset_context()
|
||||
params = Params()
|
||||
params.put_bool('OpenpilotEnabledToggle', True, block=True)
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
honda = FakeHonda(candidate, alpha_long)
|
||||
card = Car(honda, FakeRadar(honda.CP))
|
||||
self.assertEqual(card.aol_qualified, alpha_long)
|
||||
self.assertEqual(bool(card.CP.safetyConfigs[0].safetyParam & 32), alpha_long)
|
||||
with car.CarParams.from_bytes(params.get('CarParams')) as published:
|
||||
self.assertEqual(bool(published.safetyConfigs[0].safetyParam & 32), alpha_long)
|
||||
|
||||
|
||||
class HondaHostTests(unittest.TestCase):
|
||||
def test_real_car_params_selfdrive_controls_chain_fails_closed(self):
|
||||
class FakeHonda:
|
||||
CC = object()
|
||||
CS = object()
|
||||
|
||||
def __init__(self):
|
||||
self.CP = CarInterface.get_params(CAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], True, False, False)
|
||||
|
||||
def update(self, _can):
|
||||
state = car_state()
|
||||
state.cruiseState.available = True
|
||||
return state
|
||||
|
||||
def init(self, _cp, _recv, _send):
|
||||
pass
|
||||
|
||||
def apply(self, _control, _now_ns):
|
||||
return car.CarControl.Actuators(), []
|
||||
|
||||
class FakeRadar(RadarInterfaceBase):
|
||||
def update(self, can_packets):
|
||||
return None
|
||||
|
||||
with OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}):
|
||||
messaging.reset_context()
|
||||
params = Params()
|
||||
params.put_bool('OpenpilotEnabledToggle', True, block=True)
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
honda = FakeHonda()
|
||||
card = Car(honda, FakeRadar(honda.CP))
|
||||
self.assertEqual(card.CP.safetyConfigs[-1].safetyParam & 0x20, 0x20)
|
||||
with car.CarParams.from_bytes(params.get('CarParams')) as published:
|
||||
self.assertEqual(published.safetyConfigs[-1].safetyParam & 0x20, 0x20)
|
||||
selfdrive = SelfdriveD() # reads the actual card-published CarParams
|
||||
controls = Controls() # reads the same serialized CarParams
|
||||
self.assertTrue(selfdrive.aol_replay and controls.aol_replay)
|
||||
can = messaging.PubMaster(['can'])
|
||||
intent = messaging.SubMaster(['aolIntentWire'])
|
||||
can.send('can', messaging.new_message('can', 1))
|
||||
card.step()
|
||||
intent.update(100)
|
||||
self.assertTrue(intent.seen['aolIntentWire'])
|
||||
self.assertTrue(decode_intent(intent['aolIntentWire']).allowedLatch)
|
||||
selfdrive.step()
|
||||
controls.sm.update(100)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertFalse(cc.latActive or cc.longActive) # no native capability/ack
|
||||
self.assertEqual(selfdrive.aol_axis_decision.mode, 'off')
|
||||
|
||||
source = messaging.PubMaster(['aolSafetyWire', 'modelV2', 'extrinsicsCalibration', 'driverMonitoringState'])
|
||||
time.sleep(0.05) # allow native IPC subscriptions to connect before the first publication
|
||||
model = messaging.new_message('modelV2')
|
||||
model.valid = True
|
||||
calibration = messaging.new_message('extrinsicsCalibration')
|
||||
calibration.valid = True
|
||||
calibration.extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated
|
||||
dm = messaging.new_message('driverMonitoringState')
|
||||
dm.valid = True
|
||||
def publish_native(*, requested_long=False):
|
||||
native = messaging.new_message('aolSafetyWire', 0)
|
||||
native.valid = True
|
||||
observed = time.monotonic_ns()
|
||||
native.aolSafetyWire = encode_safety(SafetyState(
|
||||
1, True, observed, observed + 200_000_000, int(car.CarParams.SafetyModel.hondaBosch),
|
||||
card.CP.safetyConfigs[-1].safetyParam, True, requested_long, True, requested_long,
|
||||
'test-panda', selfdrive.aol_session_id))
|
||||
source.send('aolSafetyWire', native)
|
||||
|
||||
for _ in range(3):
|
||||
source.send('modelV2', model)
|
||||
model.clear_write_flag()
|
||||
source.send('extrinsicsCalibration', calibration)
|
||||
calibration.clear_write_flag()
|
||||
source.send('driverMonitoringState', dm)
|
||||
dm.clear_write_flag()
|
||||
publish_native()
|
||||
selfdrive.sm.update(100)
|
||||
controls.sm.update(0)
|
||||
selfdrive.initialized = True
|
||||
with mock.patch.object(selfdrive, 'update_events', side_effect=lambda _cs: selfdrive.events.clear()):
|
||||
can.send('can', messaging.new_message('can', 1))
|
||||
card.step()
|
||||
selfdrive.step()
|
||||
self.assertEqual(selfdrive.aol_axis_decision.mode, 'lateralOnly')
|
||||
controls.sm.update(0)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertTrue(cc.latActive)
|
||||
self.assertFalse(cc.longActive)
|
||||
# The native long request may still reflect a previous combined mode;
|
||||
# this must not interrupt unchanged lateral permission.
|
||||
publish_native(requested_long=True)
|
||||
controls.sm.update(0)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertTrue(cc.latActive)
|
||||
self.assertFalse(cc.longActive)
|
||||
# On the reverse transition, an old lateral-only acknowledgment keeps
|
||||
# lateral active while the newly requested longitudinal axis waits.
|
||||
publish_native()
|
||||
axis_msg = messaging.new_message('aolAxisState')
|
||||
axis_msg.valid = True
|
||||
axis = axis_msg.aolAxisState
|
||||
axis.sessionId = selfdrive.aol_session_id
|
||||
axis.qualified = True
|
||||
axis.desiredLateral = True
|
||||
axis.desiredLongitudinal = True
|
||||
axis.lateralActive = True
|
||||
axis.longitudinalActive = False
|
||||
axis.nativeAcknowledged = True
|
||||
axis.observedMonoTime = time.monotonic_ns()
|
||||
axis.validUntilMonoTime = axis.observedMonoTime + 30_000_000
|
||||
selfdrive.pm.send('aolAxisState', axis_msg)
|
||||
controls.sm.update(0)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertTrue(cc.latActive)
|
||||
self.assertFalse(cc.longActive)
|
||||
|
||||
dm.driverMonitoringState.lockout = True
|
||||
source.send('driverMonitoringState', dm)
|
||||
with mock.patch.object(selfdrive, 'update_events', side_effect=lambda _cs: selfdrive.events.clear()):
|
||||
can.send('can', messaging.new_message('can', 1))
|
||||
card.step()
|
||||
selfdrive.step()
|
||||
self.assertEqual(selfdrive.aol_axis_decision.mode, 'off')
|
||||
controls.sm.update(100)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertFalse(cc.latActive or cc.longActive)
|
||||
|
||||
# Saved AOL off retains the negotiated protocol while ending extra lateral autonomy.
|
||||
params.put_bool('AlwaysOnLateral', False, block=True)
|
||||
assert card.aol_card_intent is not None
|
||||
card.aol_card_intent.settings = read_settings(params)
|
||||
selfdrive.aol_settings = read_settings(params)
|
||||
can.send('can', messaging.new_message('can', 1))
|
||||
card.step()
|
||||
selfdrive.step()
|
||||
controls.sm.update(100)
|
||||
cc, _ = controls.state_control()
|
||||
self.assertFalse(cc.latActive or cc.longActive)
|
||||
self.assertTrue(card.aol_qualified)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Ioniq 6 host requests remain gated by a separate exact native AOL profile."""
|
||||
|
||||
import unittest
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from opendbc.can import CANParser
|
||||
from opendbc.car import Bus, gen_empty_fingerprint
|
||||
from opendbc.car.hyundai.tests.test_ioniq6_longitudinal import controller_fixture
|
||||
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, DBC, HyundaiFlags
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.controls.controlsd import Controls
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings
|
||||
from openpilot.starpilot.aol.runtime import current_native, decide_axes, monitoring_lateral_engaged
|
||||
from openpilot.starpilot.aol.vehicle import native_latch_rejected, policy_for
|
||||
from openpilot.starpilot.aol.wire import SafetyState, encode_safety
|
||||
from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable, qualified_ioniq6
|
||||
|
||||
|
||||
def candidate(alt: bool):
|
||||
fp = gen_empty_fingerprint()
|
||||
steering, support = ((0x110, 0x362) if alt else (0x50, 0x2A4))
|
||||
fp[2][steering] = 32 if alt else 16
|
||||
fp[2][support] = 32 if alt else 24
|
||||
fp[0][0x3A5] = 24
|
||||
fp[1].update({0x1CF: 8, 0x1AA: 16, 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)
|
||||
long_cp = build_ioniq6_hda2_long_candidate(stock, fp)
|
||||
assert long_cp is not None
|
||||
return stock, long_cp
|
||||
|
||||
|
||||
def state():
|
||||
return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.0)
|
||||
|
||||
|
||||
class Ioniq6HostTests(unittest.TestCase):
|
||||
def test_native_token_loss_resynchronizes_card_without_changing_native_safety(self):
|
||||
from opendbc.safety.tests.test_hyundai_ioniq6_long import TestHyundaiIoniq6Long
|
||||
|
||||
native_test = TestHyundaiIoniq6Long()
|
||||
native_test.setUp()
|
||||
if native_test.release:
|
||||
self.skipTest("RELEASE denies the Ioniq 6 AOL profiles")
|
||||
from opendbc.can import CANPacker
|
||||
for alt, raw in ((False, 0x8815), (True, 0x8895)):
|
||||
with self.subTest(alt=alt), OpenpilotPrefix():
|
||||
_, cp = candidate(alt)
|
||||
cp.safetyConfigs[0].safetyParam = raw
|
||||
native_test.mode(raw)
|
||||
safety = native_test.safety
|
||||
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
|
||||
native_test.refresh_required_rx(packer, 1, 1_000_000)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
cs = state()
|
||||
owner.update(cs, now_ns=1_000_000_000)
|
||||
sm = messaging.SubMaster(['aolSafetyWire'])
|
||||
|
||||
def buttons(counter, pressed, safety=safety, packer=packer, cs=cs, owner=owner):
|
||||
self.assertTrue(safety.safety_rx_hook(native_test.packet(packer.make_can_msg(
|
||||
'CRUISE_BUTTONS', 1, {'COUNTER': counter, 'LDA_BTN': int(pressed)}))))
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=pressed)]
|
||||
owner.update(cs, now_ns=1_000_000_000 + counter * 10_000_000)
|
||||
|
||||
def receipt(stamp, safety=safety, cp=cp, raw=raw, sm=sm):
|
||||
requested, allowed = safety.aol_get_request_mask(), safety.aol_get_permission_mask()
|
||||
message = messaging.new_message('aolSafetyWire', 0, valid=True)
|
||||
message.logMonoTime = stamp
|
||||
message.aolSafetyWire = encode_safety(SafetyState(
|
||||
1, True, stamp, stamp + 200_000_000, int(cp.safetyConfigs[0].safetyModel.raw), raw,
|
||||
bool(allowed & 1), bool(allowed & 2), bool(requested & 1), bool(requested & 2), 'panda', 'selfdrived'))
|
||||
sm.update_msgs(stamp / 1e9, [message.as_reader()])
|
||||
return current_native(sm, cp, now_ns=stamp)
|
||||
|
||||
buttons(2, True)
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
buttons(3, False)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
# Brake/gas override affect neither the physical AOL token nor its host latch.
|
||||
for name, values in (('TCS', {'DriverBraking': 1}), ('ACCELERATOR', {'ACCELERATOR_PEDAL': 100})):
|
||||
self.assertTrue(safety.safety_rx_hook(native_test.packet(packer.make_can_msg(name, 1, values))))
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
native = receipt(1_040_000_000)
|
||||
self.assertFalse(native_latch_rejected(cp, native))
|
||||
cs.buttonEvents = []
|
||||
cs.gasPressed, cs.brakePressed = True, True
|
||||
owner.update(cs, now_ns=1_040_000_000)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
safety.set_aol_test_heartbeat(False)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
safety.set_aol_test_heartbeat(True)
|
||||
safety.aol_set_host_request(1)
|
||||
native = receipt(1_050_000_000)
|
||||
self.assertTrue(native_latch_rejected(cp, native))
|
||||
owner.update(cs, now_ns=1_050_000_000, native_rejection_ns=native.observedMonoTime)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 0)
|
||||
# Native neutral after its reset, then one real LKAS edge restores both.
|
||||
buttons(6, False)
|
||||
buttons(7, True)
|
||||
safety.aol_set_host_request(1)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
self.assertEqual(safety.aol_get_permission_mask(), 1)
|
||||
|
||||
def test_latch_feedback_requires_fresh_exact_native_receipt(self):
|
||||
_, cp = candidate(False)
|
||||
cp.safetyConfigs[0].safetyParam = 0x8815
|
||||
now = 2_000_000_000
|
||||
with OpenpilotPrefix():
|
||||
sm = messaging.SubMaster(['aolSafetyWire'])
|
||||
for requested, allowed in ((False, False), (False, True), (True, True), (True, False)):
|
||||
now += 100_000_000
|
||||
msg = messaging.new_message('aolSafetyWire', 0, valid=True)
|
||||
msg.logMonoTime = now
|
||||
msg.aolSafetyWire = encode_safety(SafetyState(1, True, now, now + 200_000_000,
|
||||
int(cp.safetyConfigs[0].safetyModel.raw), 0x8815, allowed, False, requested, False, 'panda', 'session'))
|
||||
sm.update_msgs(now / 1e9, [msg.as_reader()])
|
||||
self.assertEqual(native_latch_rejected(cp, current_native(sm, cp, now_ns=now)), requested and not allowed)
|
||||
self.assertFalse(native_latch_rejected(cp, current_native(sm, cp, now_ns=now + 201_000_000)))
|
||||
self.assertFalse(native_latch_rejected(cp, current_native(sm, cp, now_ns=now - 1)))
|
||||
wrong = cp.as_reader().as_builder()
|
||||
wrong.safetyConfigs[0].safetyParam = 0x8895
|
||||
self.assertFalse(native_latch_rejected(wrong, current_native(sm, wrong, now_ns=now)))
|
||||
|
||||
def test_only_exact_tagged_hda2_long_cp_qualifies(self):
|
||||
for alt, raw in ((False, 0x8815), (True, 0x8895)):
|
||||
with self.subTest(alt=alt):
|
||||
stock, long_cp = candidate(alt)
|
||||
self.assertFalse(qualified_ioniq6(stock))
|
||||
self.assertFalse(qualified_ioniq6(long_cp))
|
||||
self.assertFalse(policy_for(long_cp).runtime_supported)
|
||||
cp = long_cp.as_reader().as_builder()
|
||||
cp.safetyConfigs[0].safetyParam = raw
|
||||
self.assertTrue(qualified_ioniq6(cp))
|
||||
self.assertTrue(policy_for(cp).runtime_supported)
|
||||
for wrong in (raw ^ 0x80, raw | 0x20, raw | 0x100, raw & ~0x8000, raw & ~0x800):
|
||||
cp.safetyConfigs[0].safetyParam = wrong
|
||||
self.assertFalse(qualified_ioniq6(cp), hex(wrong))
|
||||
cp.safetyConfigs[0].safetyParam = raw
|
||||
for field, value in (('pcmCruise', True), ('openpilotLongitudinalControl', False),
|
||||
('radarUnavailable', True), ('notCar', True), ('passive', True), ('dashcamOnly', True)):
|
||||
altered = cp.as_reader().as_builder()
|
||||
setattr(altered, field, value)
|
||||
self.assertFalse(qualified_ioniq6(altered), field)
|
||||
altered = cp.as_reader().as_builder()
|
||||
altered.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS)
|
||||
self.assertFalse(qualified_ioniq6(altered))
|
||||
altered = cp.as_reader().as_builder()
|
||||
altered.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.noOutput
|
||||
self.assertFalse(qualified_ioniq6(altered))
|
||||
|
||||
def test_stock_settings_capability_is_separate_from_runtime(self):
|
||||
for alt in (False, True):
|
||||
stock, long_cp = candidate(alt)
|
||||
self.assertTrue(ioniq6_settings_capable(stock))
|
||||
self.assertFalse(policy_for(stock).runtime_supported)
|
||||
self.assertTrue(ioniq6_settings_capable(long_cp)) # LONG saved choices do not require AOL's separate bit.
|
||||
tagged = long_cp.as_reader().as_builder()
|
||||
tagged.safetyConfigs[0].safetyParam |= 0x800
|
||||
self.assertTrue(ioniq6_settings_capable(tagged))
|
||||
self.assertTrue(policy_for(tagged).runtime_supported)
|
||||
changed = stock.as_reader().as_builder()
|
||||
changed.safetyConfigs[0].safetyParam |= 0x20
|
||||
self.assertFalse(ioniq6_settings_capable(changed))
|
||||
changed = stock.as_reader().as_builder()
|
||||
changed.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS)
|
||||
self.assertFalse(ioniq6_settings_capable(changed))
|
||||
with mock.patch('openpilot.starpilot.car.hyundai.aol.IONIQ6_LONG_PREARM_ENABLED', False):
|
||||
self.assertFalse(ioniq6_settings_capable(stock))
|
||||
|
||||
def test_tcs_availability_never_arms_explicit_lateral_latch(self):
|
||||
settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0))
|
||||
owner = AolCardIntent(settings, explicit_latch=True)
|
||||
cs = state()
|
||||
cs.cruiseState.available = True
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=False)]
|
||||
owner.update(cs)
|
||||
cs.buttonEvents = []
|
||||
cs.brakePressed = True
|
||||
cs.gasPressed = True
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.mainCruise, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = []
|
||||
cs.accFaulted = True
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
cs.accFaulted = False
|
||||
cs.canValid = False
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
|
||||
def test_startup_held_and_simultaneous_buttons_have_one_combined_edge(self):
|
||||
settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0))
|
||||
main = car.CarState.ButtonEvent.Type.mainCruise
|
||||
lkas = car.CarState.ButtonEvent.Type.lkas
|
||||
cs = state()
|
||||
owner = AolCardIntent(settings, explicit_latch=True)
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0]) # held at initialization is ignored
|
||||
cs.buttonEvents = []
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=False)]
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=True),
|
||||
car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0]) # one combined gesture, not two toggles
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=False)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
|
||||
def test_native_reset_token_never_grants_without_matching_host_request(self):
|
||||
settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0))
|
||||
owner = AolCardIntent(settings, explicit_latch=True)
|
||||
cs = state()
|
||||
owner.update(cs) # neutral baseline
|
||||
button = car.CarState.ButtonEvent.Type.lkas
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertTrue(owner.output(cs)[0])
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(cs)
|
||||
# Native can revoke its idempotent token independently after heartbeat/RX
|
||||
# loss; host's next deliberate toggle requests off, so a new native token
|
||||
# cannot grant actuation until another explicit host-on gesture.
|
||||
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(cs)
|
||||
self.assertFalse(owner.output(cs)[0])
|
||||
|
||||
def test_four_axes_require_exact_native_readback(self):
|
||||
for alt, raw in ((False, 0x8815), (True, 0x8895)):
|
||||
_, cp = candidate(alt)
|
||||
cp.safetyConfigs[0].safetyParam = raw
|
||||
stamp = 1_000_000_000
|
||||
for standard_long, lateral_latch, expected in ((False, False, 'off'), (False, True, 'lateralOnly'),
|
||||
(True, False, 'longitudinalOnly'), (True, True, 'combined')):
|
||||
cp.safetyConfigs[0].safetyParam = raw
|
||||
desired_lat, desired_long = lateral_latch, standard_long
|
||||
wire = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hyundaiCanfd), raw,
|
||||
desired_lat, desired_long, desired_lat, desired_long,
|
||||
'panda', 'drive-session'))
|
||||
class SafetySM:
|
||||
valid = {'aolSafetyWire': True}
|
||||
alive = {'aolSafetyWire': True}
|
||||
seen = {'aolSafetyWire': True}
|
||||
logMonoTime = {'aolSafetyWire': stamp}
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.payload
|
||||
|
||||
sm = SafetySM(wire)
|
||||
native = current_native(sm, cp, now_ns=stamp + 1_000_000, axis_session_id='drive-session')
|
||||
self.assertIsNotNone(native)
|
||||
intent = SimpleNamespace(allowedLatch=lateral_latch, pauseLateral=False, pauseLongitudinal=False)
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=standard_long,
|
||||
intent=intent, native=native, car_state=state(), initialized=True,
|
||||
model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False,
|
||||
pause_brake_mps=0.0)
|
||||
self.assertEqual(decision.mode, expected)
|
||||
self.assertEqual((decision.desired_lateral, decision.desired_longitudinal), (desired_lat, desired_long))
|
||||
cp.safetyConfigs[0].safetyParam = raw ^ 0x800
|
||||
self.assertIsNone(current_native(sm, cp, now_ns=stamp + 1_000_000, axis_session_id='drive-session'))
|
||||
|
||||
def test_monitoring_requires_fresh_matching_native_lateral_permission(self):
|
||||
stamp = 1_000_000_000
|
||||
class MonitorSM:
|
||||
valid = {'aolAxisState': True, 'aolSafetyWire': True}
|
||||
alive = {'aolAxisState': True, 'aolSafetyWire': True}
|
||||
seen = {'aolAxisState': True, 'aolSafetyWire': True}
|
||||
logMonoTime = {'aolAxisState': stamp, 'aolSafetyWire': stamp}
|
||||
|
||||
def __init__(self):
|
||||
self.axis = SimpleNamespace(qualified=True, sessionId='drive-session', lateralActive=True,
|
||||
nativeAcknowledged=True, observedMonoTime=stamp, validUntilMonoTime=stamp + 30_000_000)
|
||||
self.payload = b''
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.axis if key == 'aolAxisState' else self.payload
|
||||
|
||||
sm = MonitorSM()
|
||||
for raw in (0x8815, 0x8895):
|
||||
sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hyundaiCanfd), raw,
|
||||
True, False, True, False, 'panda', 'drive-session'))
|
||||
self.assertTrue(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000))
|
||||
sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hyundaiCanfd), raw,
|
||||
False, False, True, False, 'panda', 'drive-session'))
|
||||
self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000))
|
||||
self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 31_000_000))
|
||||
sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hyundaiCanfd), 0x8015,
|
||||
True, False, True, False, 'panda', 'drive-session'))
|
||||
self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000))
|
||||
|
||||
def test_actual_controls_lateral_only_command_has_active_steering_status(self):
|
||||
for alt, raw in ((False, 0x8815), (True, 0x8895)):
|
||||
with self.subTest(alt=alt), OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}):
|
||||
cp, controller_cs, controller = controller_fixture(alt, aol=True)
|
||||
self.assertEqual(cp.safetyConfigs[0].safetyParam, raw)
|
||||
Params().put('CarParams', cp.to_bytes(), block=True)
|
||||
controls = Controls()
|
||||
self.assertTrue(controls.aol_replay)
|
||||
cs = state()
|
||||
cs.cruiseState.available = True
|
||||
controls.sm.data['carState'] = cs.as_reader()
|
||||
axis = SimpleNamespace(sessionId='drive-session', nativeAcknowledged=True, lateralActive=True,
|
||||
longitudinalActive=False, desiredLateral=True, desiredLongitudinal=False)
|
||||
native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False,
|
||||
lateralAllowed=True, longitudinalAllowed=False)
|
||||
with mock.patch('openpilot.selfdrive.controls.controlsd.current_axis', return_value=axis), \
|
||||
mock.patch('openpilot.selfdrive.controls.controlsd.current_native', return_value=native):
|
||||
command, _ = controls.state_control()
|
||||
self.assertFalse(command.enabled)
|
||||
self.assertTrue(command.latActive)
|
||||
self.assertFalse(command.longActive)
|
||||
_, frames = controller.update(command.as_reader(), controller_cs, 1_000_000_000)
|
||||
lfa = next(frame for frame in frames if frame[0] == 0x12A)
|
||||
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LFA', 0)], 1)
|
||||
parser.update((1_000_000_000, [lfa]))
|
||||
self.assertEqual(parser.vl['LFA']['LKA_SysIndReq'], 2)
|
||||
self.assertEqual(parser.vl['LFA']['ActToiSta'], 1)
|
||||
scc = next(frame for frame in frames if frame[0] == 0x1A0)
|
||||
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1)
|
||||
parser.update((1_000_000_000, [scc]))
|
||||
self.assertEqual(parser.vl['SCC_CONTROL']['ACCMode'], 0)
|
||||
self.assertEqual(parser.vl['SCC_CONTROL']['aReqValue'], 0.)
|
||||
self.assertEqual(parser.vl['SCC_CONTROL']['aReqRaw'], 0.)
|
||||
|
||||
def test_serialized_ioniq_axis_and_native_receipts_drive_both_controls_and_expire(self):
|
||||
with OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '0', 'SIMULATION': '1'}):
|
||||
cp, _, _ = controller_fixture(True, aol=True)
|
||||
params = Params()
|
||||
params.put('CarParams', cp.to_bytes(), block=True)
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
controls = Controls()
|
||||
self.assertTrue(controls.aol_replay)
|
||||
base = time.monotonic_ns()
|
||||
|
||||
def command(stamp: int, *, longitudinal: bool, native_receipt: bool = True):
|
||||
car_msg = messaging.new_message('carState', valid=True)
|
||||
car_msg.logMonoTime = stamp
|
||||
car_msg.carState = state()
|
||||
drive_msg = messaging.new_message('selfdriveState', valid=True)
|
||||
drive_msg.logMonoTime = stamp
|
||||
drive_msg.selfdriveState.enabled = longitudinal
|
||||
axis_msg = messaging.new_message('aolAxisState', valid=True)
|
||||
axis_msg.logMonoTime = stamp
|
||||
axis = axis_msg.aolAxisState
|
||||
axis.qualified = axis.nativeAcknowledged = axis.lateralActive = axis.desiredLateral = True
|
||||
axis.longitudinalActive = axis.desiredLongitudinal = longitudinal
|
||||
axis.sessionId = 'ioniq-drive'
|
||||
axis.sourceCarStateMonoTime = stamp
|
||||
axis.observedMonoTime = stamp
|
||||
axis.validUntilMonoTime = stamp + 30_000_000
|
||||
events = [car_msg, drive_msg, axis_msg]
|
||||
if native_receipt:
|
||||
safety_msg = messaging.new_message('aolSafetyWire', 0, valid=True)
|
||||
safety_msg.logMonoTime = stamp
|
||||
safety_msg.aolSafetyWire = encode_safety(SafetyState(
|
||||
1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hyundaiCanfd), 0x8895,
|
||||
True, longitudinal, True, longitudinal, 'panda', 'ioniq-drive'))
|
||||
events.append(safety_msg)
|
||||
controls.sm.update_msgs(stamp / 1e9, [messaging.log_from_bytes(event.to_bytes()) for event in events])
|
||||
with mock.patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=stamp + 1_000_000):
|
||||
return controls.state_control()[0]
|
||||
|
||||
combined = command(base, longitudinal=True)
|
||||
self.assertTrue(combined.latActive and combined.longActive)
|
||||
lateral_only = command(base + 10_000_000, longitudinal=False)
|
||||
self.assertTrue(lateral_only.latActive)
|
||||
self.assertFalse(lateral_only.longActive)
|
||||
restored = command(base + 20_000_000, longitudinal=True)
|
||||
self.assertTrue(restored.latActive and restored.longActive)
|
||||
lost = command(base + 250_000_000, longitudinal=True, native_receipt=False)
|
||||
self.assertFalse(lost.latActive or lost.longActive)
|
||||
@@ -0,0 +1,115 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from opendbc.car import gen_empty_fingerprint
|
||||
from opendbc.car.can_definitions import CanData
|
||||
from opendbc.car.gm.interface import CarInterface as GMInterface
|
||||
from opendbc.car.gm.values import CAR as GMCar, GMFlags
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.selfdrive.car.card import Car, EventName
|
||||
from openpilot.selfdrive.pandad import can_capnp_to_list
|
||||
|
||||
|
||||
class CardInitLifecycleTest(unittest.TestCase):
|
||||
def test_bolt_pedal_waits_for_control_then_initializes_once_before_output(self):
|
||||
fingerprint = gen_empty_fingerprint()
|
||||
fingerprint[0][0x201] = 6
|
||||
with patch('opendbc.car.gm.interface.Params') as saved:
|
||||
saved.return_value.get_bool.return_value = True
|
||||
cp = GMInterface.get_params(GMCar.CHEVROLET_BOLT_ACC_2022_2023_PEDAL,
|
||||
fingerprint, [], False, False, False)
|
||||
self.assertTrue(cp.flags & GMFlags.PEDAL_LONG.value)
|
||||
|
||||
order = []
|
||||
card = Car.__new__(Car)
|
||||
card.CP = cp
|
||||
card.ci_initialized = False
|
||||
card.initialized_prev = False
|
||||
card.ioniq6_long_prearmed = False
|
||||
card.ioniq6_long_selected = False
|
||||
card.can_callbacks = (lambda wait_for_one=False: [], lambda frames: None)
|
||||
|
||||
def apply(*_):
|
||||
order.append('apply')
|
||||
return object(), [CanData(0x200, b'\x00' * 8, 0)]
|
||||
|
||||
fake_ci = SimpleNamespace(
|
||||
init=Mock(side_effect=lambda *_: order.append('init')),
|
||||
apply=Mock(side_effect=apply),
|
||||
)
|
||||
self.enterContext(patch.object(card, 'CI', fake_ci, create=True))
|
||||
self.enterContext(patch.object(card, 'params', SimpleNamespace(put_bool=lambda key, value: order.append((key, value))), create=True))
|
||||
sent = []
|
||||
|
||||
def publish(service, packet):
|
||||
assert service == 'sendcan'
|
||||
sent.append(packet)
|
||||
order.append(('sendcan', tuple(can_capnp_to_list([packet], msgtype='sendcan')[0][1]),
|
||||
bool(messaging.log_from_bytes(packet).valid)))
|
||||
|
||||
self.enterContext(patch.object(card, 'pm', SimpleNamespace(send=publish), create=True))
|
||||
card.state_publish = Mock(side_effect=lambda *_: order.append('carState'))
|
||||
cs = SimpleNamespace(canValid=True, canTimeout=False)
|
||||
card.state_update = Mock(return_value=(cs, None))
|
||||
control = object()
|
||||
|
||||
class SubMaster:
|
||||
valid = {'carControl': False}
|
||||
alive = {'carControl': False}
|
||||
seen = {'onroadEvents': False}
|
||||
events = []
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.events if key == 'onroadEvents' else control
|
||||
|
||||
def all_alive(self, services):
|
||||
return all(self.alive[name] for name in services)
|
||||
|
||||
sm = SubMaster()
|
||||
self.enterContext(patch.object(card, 'sm', sm, create=True))
|
||||
for _ in range(30):
|
||||
card.step()
|
||||
self.assertNotIn('init', order)
|
||||
sm.seen['onroadEvents'] = True
|
||||
sm.events = [SimpleNamespace(name=EventName.selfdriveInitializing)]
|
||||
card.step()
|
||||
self.assertNotIn('init', order)
|
||||
|
||||
sm.events = []
|
||||
card.step() # Controls still have no live producer.
|
||||
self.assertNotIn('init', order)
|
||||
sm.valid['carControl'] = True
|
||||
sm.alive['carControl'] = True
|
||||
cs.canValid = False
|
||||
card.step() # A live controller cannot initialize against invalid CAN.
|
||||
self.assertNotIn('init', order)
|
||||
|
||||
cs.canValid = True
|
||||
card.step()
|
||||
self.assertEqual(order[-5:-1], ['carState', 'init', ('ControlsReady', True), 'apply'])
|
||||
self.assertEqual(order[-1][0], 'sendcan')
|
||||
self.assertEqual([(frame[0], frame[2]) for frame in order[-1][1]], [(0x200, 0)])
|
||||
self.assertTrue(order[-1][2])
|
||||
self.assertTrue(all(not can_capnp_to_list([packet], msgtype='sendcan')[0][1] for packet in sent[:-1]))
|
||||
self.assertTrue(all(not messaging.log_from_bytes(packet).valid for packet in sent[:-1]))
|
||||
card.step()
|
||||
fake_ci.init.assert_called_once_with(cp, *card.can_callbacks)
|
||||
self.assertEqual(order.count(('ControlsReady', True)), 1)
|
||||
|
||||
applied = fake_ci.apply.call_count
|
||||
sent_before = len(sent)
|
||||
sm.valid['carControl'] = False
|
||||
card.step() # A live but invalid native command cannot renew CAN output.
|
||||
self.assertEqual(fake_ci.apply.call_count, applied)
|
||||
self.assertEqual(len(sent), sent_before)
|
||||
sm.valid['carControl'] = True
|
||||
card.step()
|
||||
self.assertEqual(fake_ci.apply.call_count, applied + 1)
|
||||
self.assertEqual(len(sent), sent_before + 1)
|
||||
self.assertTrue(messaging.log_from_bytes(sent[-1]).valid)
|
||||
fake_ci.init.assert_called_once_with(cp, *card.can_callbacks)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Capability-aware preference enrollment, separate from runtime authority."""
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.starpilot.aol.vehicle import policy_for
|
||||
from openpilot.starpilot.state_migration import _atomic_write, _params_lock, _read_value
|
||||
|
||||
|
||||
def default_enabled(cp) -> bool:
|
||||
if cp is None or getattr(cp, 'notCar', True) or getattr(cp, 'passive', True) or getattr(cp, 'dashcamOnly', True):
|
||||
return False
|
||||
policy = policy_for(cp)
|
||||
return bool(policy.settings_supported and policy.intent_supported)
|
||||
|
||||
|
||||
def enroll_missing_preference(params, cp) -> bool:
|
||||
if not default_enabled(cp):
|
||||
return False
|
||||
try:
|
||||
namespace = Path(params.get_param_path()).absolute()
|
||||
with _params_lock(namespace):
|
||||
try:
|
||||
_read_value(namespace / 'AlwaysOnLateral')
|
||||
except FileNotFoundError:
|
||||
_atomic_write(namespace / 'AlwaysOnLateral', b'1')
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return False
|
||||
@@ -0,0 +1,63 @@
|
||||
def armed_lateral_current(cp, cs, intent, native, *, source_ns, now_ns, axis_session_id) -> bool:
|
||||
"""Retain only a current deliberately armed native-permitted lateral owner."""
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.aol.vehicle import policy_for
|
||||
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
|
||||
try:
|
||||
return bool(policy_for(cp).intent_supported and
|
||||
cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and
|
||||
not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and
|
||||
cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
|
||||
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and
|
||||
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
|
||||
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
|
||||
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - source_ns <= AXIS_MAX_AGE_NS and
|
||||
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
|
||||
native is not None and native.compatible and int(native.protocolVersion) == 1 and
|
||||
axis_session_id and native.axisSessionId == axis_session_id and
|
||||
native.requestedLateral and native.lateralAllowed and native.pandaInventory and
|
||||
native_inventory_matches_cp(native, cp) and
|
||||
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and
|
||||
native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS)
|
||||
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def lateral_event_context(self, CS, intent, native, now_ns, ordinary_lag):
|
||||
from openpilot.selfdrive.selfdrived.events import EventName
|
||||
from openpilot.cereal import log
|
||||
dm = self.sm['driverMonitoringState']
|
||||
needs_exception = bool(dm.alertLevel == log.DriverMonitoringState.AlertLevel.three or
|
||||
dm.lockout or dm.alwaysOnLockout or EventName.tooDistracted in self.events.names)
|
||||
retained_dm_lateral = bool(needs_exception and intent is not None and
|
||||
getattr(self, 'aol_dm_retained_owner', None) == (intent.producerSessionId, self.aol_session_id) and
|
||||
self.sm.all_checks(['driverMonitoringState']) and
|
||||
armed_lateral_current(self.CP, CS, intent, native, source_ns=self.aol_car_state_log_ns,
|
||||
now_ns=now_ns, axis_session_id=self.aol_session_id))
|
||||
lateral_events = [name for name in self.events.names if not (
|
||||
name == EventName.selfdrivedLagging and ordinary_lag or
|
||||
name == EventName.tooDistracted and retained_dm_lateral)]
|
||||
return retained_dm_lateral, lateral_events
|
||||
|
||||
|
||||
def observe_owner(self, intent, previous_intent):
|
||||
from openpilot.cereal import log
|
||||
AlertLevel = log.DriverMonitoringState.AlertLevel
|
||||
if (intent is None or not intent.allowedLatch or previous_intent is None or
|
||||
intent.producerSessionId != previous_intent.producerSessionId):
|
||||
self.aol_dm_retained_owner = None
|
||||
elif self.aol_axis_decision.lateral_active and previous_intent.allowedLatch:
|
||||
self.aol_dm_retained_owner = (intent.producerSessionId, self.aol_session_id)
|
||||
if (self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or
|
||||
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout):
|
||||
self.aol_availability_long_inhibit = True
|
||||
self.aol_dm_lateral_inhibit = False
|
||||
elif intent is not None and not intent.allowedLatch:
|
||||
self.aol_dm_lateral_inhibit = False
|
||||
|
||||
|
||||
def dm_clear(self):
|
||||
from openpilot.cereal import log
|
||||
return (self.sm['driverMonitoringState'].alertLevel != log.DriverMonitoringState.AlertLevel.three and
|
||||
not self.sm['driverMonitoringState'].lockout and not self.sm['driverMonitoringState'].alwaysOnLockout)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Original carState companions from Card's nonconflating intent transport."""
|
||||
from dataclasses import replace
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS, SAFETY_MAX_AGE_NS
|
||||
from openpilot.starpilot.aol.wire import INTENT_SERVICE, decode_intent
|
||||
|
||||
MAX_COMPANIONS = 16
|
||||
MAX_DRAIN = 32
|
||||
|
||||
|
||||
class IntentCompanion:
|
||||
def __init__(self, sock):
|
||||
self.sock = sock
|
||||
self.samples = {}
|
||||
self.latest = None
|
||||
self.session = None
|
||||
self.sequence = -1
|
||||
self.sequence_stamp = 0
|
||||
self.integrity_epoch = 0
|
||||
self.verified_sm_projection = None
|
||||
self.raw_projections = {}
|
||||
self.last_rejection = None
|
||||
self.last_drain_count = 0
|
||||
self.last_drain_ok = True
|
||||
|
||||
def _observe(self, stamp, valid, raw):
|
||||
stamp = int(stamp)
|
||||
intent = decode_intent(raw)
|
||||
valid = bool(valid and intent is not None and intent.carStateLogMonoTime == stamp)
|
||||
previous = self.samples.get(stamp)
|
||||
if self.latest is not None and stamp < self.latest[0] and previous is None:
|
||||
valid = False
|
||||
if intent is not None and (self.latest is None or stamp >= self.latest[0]):
|
||||
if self.session is not None and intent.producerSessionId != self.session:
|
||||
self.integrity_epoch += 1
|
||||
self.verified_sm_projection = None
|
||||
self.raw_projections.clear()
|
||||
self.samples.clear()
|
||||
self.sequence = -1
|
||||
self.sequence_stamp = 0
|
||||
self.session = intent.producerSessionId
|
||||
if previous is not None:
|
||||
valid = bool(valid and previous[1] and previous[2] == intent)
|
||||
if valid and intent is not None:
|
||||
if (stamp > self.sequence_stamp and intent.producerSessionId == self.session and
|
||||
intent.sequence <= self.sequence):
|
||||
valid = False
|
||||
for other_stamp, other_valid, other in self.samples.values():
|
||||
if other_valid and other is not None and other.producerSessionId == intent.producerSessionId and (
|
||||
other_stamp < stamp and other.sequence >= intent.sequence or
|
||||
other_stamp > stamp and other.sequence <= intent.sequence):
|
||||
valid = False
|
||||
if valid and intent is not None and (self.latest is None or stamp >= self.latest[0]):
|
||||
self.sequence = max(self.sequence, intent.sequence)
|
||||
self.sequence_stamp = max(self.sequence_stamp, stamp)
|
||||
if not valid:
|
||||
self.integrity_epoch += 1
|
||||
self.verified_sm_projection = None
|
||||
self.raw_projections.clear()
|
||||
sample = (stamp, valid, intent)
|
||||
self.samples[stamp] = sample
|
||||
if self.latest is None or stamp >= self.latest[0]:
|
||||
self.latest = sample
|
||||
while len(self.samples) > MAX_COMPANIONS:
|
||||
del self.samples[min(self.samples)]
|
||||
|
||||
@staticmethod
|
||||
def _fresh(sample, now_ns):
|
||||
stamp, valid, intent = sample
|
||||
return bool(valid and intent is not None and intent.settingsQualified and intent.producerSessionId and
|
||||
0 < stamp <= now_ns and now_ns - stamp <= INTENT_MAX_AGE_NS and
|
||||
intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS)
|
||||
|
||||
def drain(self, *, max_messages=MAX_DRAIN):
|
||||
"""Read bounded transport before sampling the decision clock."""
|
||||
self.last_drain_count = 0
|
||||
self.last_drain_ok = False
|
||||
for _ in range(max(0, min(MAX_DRAIN, max_messages))):
|
||||
message = messaging.recv_one_or_none(self.sock)
|
||||
if message is None:
|
||||
break
|
||||
self.last_drain_count += 1
|
||||
self._observe(message.logMonoTime, message.valid, message.aolIntentWire)
|
||||
sample = self.samples.get(int(message.logMonoTime))
|
||||
if sample is not None and sample[1]:
|
||||
self.raw_projections[sample[0]] = (sample[2], self.integrity_epoch, self.session)
|
||||
while len(self.raw_projections) > MAX_COMPANIONS:
|
||||
del self.raw_projections[min(self.raw_projections)]
|
||||
else:
|
||||
self.integrity_epoch += 1
|
||||
self.verified_sm_projection = None
|
||||
self.raw_projections.clear()
|
||||
self.samples.clear()
|
||||
if self.latest is not None:
|
||||
self.latest = (self.latest[0], False, self.latest[2])
|
||||
self.samples[self.latest[0]] = self.latest
|
||||
self.last_rejection = 'drain_overrun'
|
||||
return False
|
||||
self.last_drain_ok = True
|
||||
return True
|
||||
|
||||
def _reject(self, reason):
|
||||
self.last_rejection = reason
|
||||
return None
|
||||
|
||||
def current(self, sm, *, source_ns, now_ns, drain=True):
|
||||
self.last_rejection = None
|
||||
if drain and not self.drain():
|
||||
return self._reject('drain_overrun')
|
||||
if not self.last_drain_ok:
|
||||
return self._reject('drain_overrun')
|
||||
projection = None
|
||||
if sm.seen.get(INTENT_SERVICE, False):
|
||||
stamp = int(sm.logMonoTime[INTENT_SERVICE])
|
||||
raw_verified = self.raw_projections.get(stamp)
|
||||
raw_current = bool(raw_verified is not None and raw_verified[0].settingsQualified and
|
||||
raw_verified[0].producerSessionId and 0 < raw_verified[0].observedMonoTime <= now_ns <=
|
||||
raw_verified[0].validUntilMonoTime)
|
||||
verified = ((stamp, *raw_verified) if raw_current else self.verified_sm_projection)
|
||||
if (verified is not None and verified[0] == stamp and verified[2] == self.integrity_epoch and
|
||||
verified[3] == self.session and sm.valid.get(INTENT_SERVICE, False) and
|
||||
sm.alive.get(INTENT_SERVICE, False) and 0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS and
|
||||
(stamp < source_ns or (source_ns == 0 or source_ns == stamp) and
|
||||
0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS) and
|
||||
now_ns - stamp > INTENT_MAX_AGE_NS and decode_intent(sm[INTENT_SERVICE]) == verified[1]):
|
||||
# This exact previously accepted SM identity contributes restrictions
|
||||
# only; it never replaces the fresh raw source pair or latest sample.
|
||||
projection = (stamp, True, verified[1])
|
||||
else:
|
||||
self._observe(stamp, sm.valid.get(INTENT_SERVICE, False), sm[INTENT_SERVICE])
|
||||
for stamp in tuple(self.samples):
|
||||
if not 0 < stamp <= now_ns or now_ns - stamp > INTENT_MAX_AGE_NS:
|
||||
del self.samples[stamp]
|
||||
for stamp in tuple(self.raw_projections):
|
||||
if not 0 < stamp <= now_ns or now_ns - stamp > SAFETY_MAX_AGE_NS:
|
||||
del self.raw_projections[stamp]
|
||||
if not (sm.seen.get(INTENT_SERVICE, False) and sm.valid.get(INTENT_SERVICE, False) and
|
||||
sm.alive.get(INTENT_SERVICE, False)):
|
||||
return self._reject('sm_unavailable')
|
||||
pair = self.samples.get(source_ns)
|
||||
sm_sample = projection if projection is not None else self.samples.get(int(sm.logMonoTime[INTENT_SERVICE]))
|
||||
if pair is None or self.latest is None or sm_sample is None:
|
||||
return self._reject('missing_pair_latest_or_sm')
|
||||
fresh_samples = (pair, self.latest) if projection is not None else (pair, self.latest, sm_sample)
|
||||
for label, sample in zip(('pair', 'latest', 'sm'), fresh_samples, strict=False):
|
||||
if not self._fresh(sample, now_ns):
|
||||
stamp, valid, value = sample
|
||||
reason = ('invalid' if not valid or value is None else
|
||||
'settings_or_session' if not value.settingsQualified or not value.producerSessionId else
|
||||
'future' if stamp > now_ns or value.observedMonoTime > now_ns else
|
||||
'nonpositive_stamp' if stamp <= 0 else
|
||||
'expired')
|
||||
return self._reject(label + '_' + reason)
|
||||
if self.latest[0] < source_ns:
|
||||
return self._reject('latest_older_than_source')
|
||||
intent = pair[2]
|
||||
for sample in (self.latest, sm_sample):
|
||||
_, _, latest = sample
|
||||
if latest.producerSessionId != intent.producerSessionId:
|
||||
return self._reject('producer_session_mismatch')
|
||||
intent = replace(intent, allowedLatch=intent.allowedLatch and latest.allowedLatch,
|
||||
pauseLateral=intent.pauseLateral or latest.pauseLateral,
|
||||
pauseLongitudinal=intent.pauseLongitudinal or latest.pauseLongitudinal,
|
||||
lateralArmed=intent.lateralArmed and latest.lateralArmed,
|
||||
optionalSetRelease=intent.optionalSetRelease and latest.optionalSetRelease)
|
||||
if projection is None:
|
||||
# Save only after every ordinary fresh pair/latest/SM guard succeeded.
|
||||
self.verified_sm_projection = (sm_sample[0], sm_sample[2], self.integrity_epoch, self.session)
|
||||
return intent
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Bounded AOL axis requests and native acknowledgment checks.
|
||||
|
||||
The requester and the actuator are deliberately separate: a native permission
|
||||
cannot be required before the host sends its first desired-axis request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.aol.vehicle import native_admission_key, native_matches_cp, native_profile_supported
|
||||
from openpilot.starpilot.aol.wire import INTENT_SERVICE, SAFETY_SERVICE, IntentState, decode_intent, decode_safety
|
||||
|
||||
INTENT_MAX_AGE_NS = 30_000_000
|
||||
SAFETY_MAX_AGE_NS = 200_000_000
|
||||
AXIS_MAX_AGE_NS = 30_000_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisDecision:
|
||||
desired_lateral: bool = False
|
||||
desired_longitudinal: bool = False
|
||||
lateral_active: bool = False
|
||||
longitudinal_active: bool = False
|
||||
native_acknowledged: bool = False
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
if self.lateral_active and self.longitudinal_active:
|
||||
return 'combined'
|
||||
if self.lateral_active:
|
||||
return 'lateralOnly'
|
||||
if self.longitudinal_active:
|
||||
return 'longitudinalOnly'
|
||||
return 'off'
|
||||
|
||||
|
||||
def current_intent(sm, *, car_state_ns: int, now_ns: int, previous: IntentState | None = None, companion=None, drain=True):
|
||||
if companion is not None:
|
||||
return companion.current(sm, source_ns=car_state_ns, now_ns=now_ns, drain=drain)
|
||||
if not (sm.valid[INTENT_SERVICE] and sm.alive[INTENT_SERVICE] and sm.seen[INTENT_SERVICE]):
|
||||
return None
|
||||
intent = decode_intent(sm[INTENT_SERVICE])
|
||||
if intent is None:
|
||||
return None
|
||||
# Intent is published before its carState wakeup. If the carState socket
|
||||
# times out exactly between those sends, only the already accepted companion
|
||||
# for that *same retained carState* may bridge the publication boundary.
|
||||
# Revalidate its original lease; never accept a newer intent for older CAN.
|
||||
if (intent.carStateLogMonoTime > car_state_ns and previous is not None and
|
||||
previous.carStateLogMonoTime == car_state_ns and intent.settingsQualified and
|
||||
intent.producerSessionId == previous.producerSessionId and
|
||||
int(sm.logMonoTime[INTENT_SERVICE]) == intent.carStateLogMonoTime and
|
||||
intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS):
|
||||
intent = previous
|
||||
message_ns = int(previous.carStateLogMonoTime)
|
||||
else:
|
||||
message_ns = int(sm.logMonoTime[INTENT_SERVICE])
|
||||
observed = int(intent.observedMonoTime)
|
||||
produced_for = int(intent.carStateLogMonoTime)
|
||||
if (not intent.settingsQualified or not intent.producerSessionId or produced_for <= 0 or
|
||||
message_ns != produced_for or
|
||||
produced_for > car_state_ns or car_state_ns - produced_for > INTENT_MAX_AGE_NS or
|
||||
observed > now_ns or now_ns > int(intent.validUntilMonoTime) or
|
||||
now_ns - observed > INTENT_MAX_AGE_NS):
|
||||
return None
|
||||
return intent
|
||||
|
||||
|
||||
def native_inventory_matches_cp(state, CP) -> bool:
|
||||
"""Bind the selected serial to the ordered same-read configuration inventory.
|
||||
|
||||
Historical v1 wires retain single-Panda compatibility only; they carry no
|
||||
inventory qualification. Multi-Panda admission requires coherent production v2
|
||||
with the exact configuration and one owner.
|
||||
The source PandaStates stamp is BOOTTIME provenance, never a monotonic lease.
|
||||
"""
|
||||
if not state.pandaInventory:
|
||||
return state.sourcePandaStatesMonoTime == 0 and len(CP.safetyConfigs) == 1
|
||||
try:
|
||||
slots = state.pandaInventory
|
||||
configs = CP.safetyConfigs
|
||||
if state.sourcePandaStatesMonoTime <= 0 or not 1 <= len(slots) <= 2 or len(slots) != len(configs):
|
||||
return False
|
||||
serials = [slot.hardwareSerial for slot in slots]
|
||||
if any(not serial for serial in serials) or len(set(serials)) != len(serials):
|
||||
return False
|
||||
owners = []
|
||||
for index, (slot, config) in enumerate(zip(slots, configs, strict=True)):
|
||||
if slot.safetyRxChecksInvalid or slot.heartbeatLost or slot.faults:
|
||||
return False
|
||||
if (slot.slotIndex != index or slot.safetyModel != int(config.safetyModel.raw) or
|
||||
slot.safetyParam != int(config.safetyParam) or slot.alternativeExperience != int(CP.alternativeExperience)):
|
||||
return False
|
||||
if str(config.safetyModel) in ('silent', 'noOutput'):
|
||||
if slot.safetyParam != 0 or slot.controlsAllowed:
|
||||
return False
|
||||
else:
|
||||
owners.append(slot)
|
||||
return bool(len(owners) == 1 and owners[0].hardwareSerial == state.pandaSerial and
|
||||
owners[0].safetyModel == state.safetyModel and owners[0].safetyParam == state.safetyParam)
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
def _clear_native_admission(sm):
|
||||
try:
|
||||
sm._aol_native_admission = None
|
||||
except Exception:
|
||||
# Custom/slotted owners may not support this optional optimization.
|
||||
pass
|
||||
|
||||
|
||||
def _native_admission_matches(sm, CP, state, raw):
|
||||
key = None
|
||||
try:
|
||||
cp_key = native_admission_key(CP) if state.pandaInventory else None
|
||||
if cp_key is not None and isinstance(raw, bytes):
|
||||
key = (raw, int(sm.logMonoTime[SAFETY_SERVICE]), cp_key)
|
||||
if getattr(sm, '_aol_native_admission', None) == key:
|
||||
return True
|
||||
except Exception:
|
||||
# Cache plumbing may never replace or prevent the original validation.
|
||||
key = None
|
||||
matches = (native_matches_cp(CP, int(state.safetyModel), int(state.safetyParam)) and
|
||||
native_inventory_matches_cp(state, CP))
|
||||
try:
|
||||
# Re-read mutable CP on a miss before binding the successful result.
|
||||
sm._aol_native_admission = key if matches and key is not None and native_admission_key(CP) == key[2] else None
|
||||
except Exception:
|
||||
_clear_native_admission(sm)
|
||||
return matches
|
||||
|
||||
|
||||
def current_native(sm, CP, *, now_ns: int, axis_session_id: str | None = None):
|
||||
if not (sm.valid[SAFETY_SERVICE] and sm.alive[SAFETY_SERVICE] and sm.seen[SAFETY_SERVICE]):
|
||||
_clear_native_admission(sm)
|
||||
return None
|
||||
raw = sm[SAFETY_SERVICE]
|
||||
raw = bytes(raw) if isinstance(raw, (bytes, bytearray, memoryview)) else raw
|
||||
state = decode_safety(raw)
|
||||
if state is None:
|
||||
_clear_native_admission(sm)
|
||||
return None
|
||||
if (not _native_admission_matches(sm, CP, state, raw) or
|
||||
not state.compatible or int(state.protocolVersion) != 1 or not state.pandaSerial or not state.axisSessionId or
|
||||
(axis_session_id is not None and str(state.axisSessionId) != axis_session_id) or
|
||||
int(sm.logMonoTime[SAFETY_SERVICE]) > now_ns or
|
||||
now_ns - int(sm.logMonoTime[SAFETY_SERVICE]) > SAFETY_MAX_AGE_NS or
|
||||
int(state.observedMonoTime) > now_ns or now_ns > int(state.validUntilMonoTime)):
|
||||
_clear_native_admission(sm)
|
||||
return None
|
||||
if state.pandaInventory and (int(sm.logMonoTime[SAFETY_SERVICE]) != state.observedMonoTime or
|
||||
state.observedMonoTime <= 0 or
|
||||
state.validUntilMonoTime - state.observedMonoTime > SAFETY_MAX_AGE_NS):
|
||||
_clear_native_admission(sm)
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def decide_axes(*, standard_lateral: bool, standard_longitudinal: bool, intent,
|
||||
native, car_state, initialized: bool, model_ready: bool,
|
||||
no_entry: bool, immediate_disable: bool, dm_lockout: bool,
|
||||
pause_brake_mps: float, lateral_inhibit: bool = False) -> AxisDecision:
|
||||
if intent is None or not initialized or not car_state.canValid or immediate_disable or no_entry or dm_lockout:
|
||||
return AxisDecision()
|
||||
gear = car_state.gearShifter
|
||||
driving = gear not in (car.CarState.GearShifter.neutral, car.CarState.GearShifter.park,
|
||||
car.CarState.GearShifter.reverse, car.CarState.GearShifter.unknown)
|
||||
lateral_safe = (driving and model_ready and not lateral_inhibit and not car_state.steerFaultPermanent and
|
||||
not car_state.steerFaultTemporary and
|
||||
(pause_brake_mps <= 0 or not car_state.brakePressed or car_state.vEgo >= pause_brake_mps or car_state.standstill))
|
||||
desired_lateral = bool(lateral_safe and not intent.pauseLateral and (standard_lateral or intent.allowedLatch))
|
||||
desired_longitudinal = bool(driving and standard_longitudinal and not car_state.seatbeltUnlatched and not intent.pauseLongitudinal)
|
||||
lateral_ack = native is not None and bool(native.requestedLateral) == desired_lateral
|
||||
longitudinal_ack = native is not None and bool(native.requestedLongitudinal) == desired_longitudinal
|
||||
return AxisDecision(
|
||||
desired_lateral=desired_lateral, desired_longitudinal=desired_longitudinal,
|
||||
lateral_active=bool(desired_lateral and lateral_ack and native.lateralAllowed),
|
||||
longitudinal_active=bool(desired_longitudinal and longitudinal_ack and native.longitudinalAllowed),
|
||||
native_acknowledged=bool(native is not None),
|
||||
)
|
||||
|
||||
|
||||
def current_axis(sm, *, now_ns: int):
|
||||
if not (sm.valid['aolAxisState'] and sm.alive['aolAxisState'] and sm.seen['aolAxisState']):
|
||||
return None
|
||||
state = sm['aolAxisState']
|
||||
if (not state.qualified or not state.sessionId or
|
||||
int(sm.logMonoTime['aolAxisState']) > now_ns or
|
||||
now_ns - int(sm.logMonoTime['aolAxisState']) > AXIS_MAX_AGE_NS or
|
||||
int(state.observedMonoTime) > now_ns or now_ns > int(state.validUntilMonoTime)):
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def monitoring_lateral_engaged(sm, *, now_ns: int) -> bool:
|
||||
axis = current_axis(sm, now_ns=now_ns)
|
||||
if axis is None or not axis.lateralActive or not axis.nativeAcknowledged:
|
||||
return False
|
||||
if not (sm.valid[SAFETY_SERVICE] and sm.seen[SAFETY_SERVICE] and sm.alive[SAFETY_SERVICE]):
|
||||
return False
|
||||
native = decode_safety(sm[SAFETY_SERVICE])
|
||||
if native is None:
|
||||
return False
|
||||
return bool(native.compatible and int(native.protocolVersion) == 1 and
|
||||
native.axisSessionId == axis.sessionId and
|
||||
native_profile_supported(int(native.safetyModel), int(native.safetyParam)) and
|
||||
int(sm.logMonoTime[SAFETY_SERVICE]) <= now_ns and
|
||||
now_ns - int(sm.logMonoTime[SAFETY_SERVICE]) <= SAFETY_MAX_AGE_NS and
|
||||
int(native.observedMonoTime) <= now_ns <= int(native.validUntilMonoTime) and
|
||||
native.requestedLateral and native.lateralAllowed)
|
||||
|
||||
|
||||
def ordinary_lateral_requested(active: bool, CS, CP) -> bool:
|
||||
"""The standard lateral output gate, shared by request and actuation owners."""
|
||||
standstill = abs(CS.vEgo) <= max(CP.minSteerSpeed, 0.3) or CS.standstill
|
||||
return bool(active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and
|
||||
(not standstill or CP.steerAtStandstill))
|
||||
|
||||
|
||||
def decide_ordinary_axis(*, requested: bool, native) -> AxisDecision:
|
||||
"""Acknowledgment cannot create driver intent or an independent axis."""
|
||||
acknowledged = bool(native is not None and native.requestedLateral == requested and
|
||||
not native.requestedLongitudinal)
|
||||
return AxisDecision(desired_lateral=bool(requested),
|
||||
lateral_active=bool(requested and acknowledged and native.lateralAllowed),
|
||||
native_acknowledged=acknowledged)
|
||||
|
||||
|
||||
def ordinary_axis_acknowledged(sm, CP, *, now_ns: int) -> bool:
|
||||
axis = current_axis(sm, now_ns=now_ns)
|
||||
native = current_native(sm, CP, now_ns=now_ns,
|
||||
axis_session_id=str(axis.sessionId) if axis is not None else None)
|
||||
return bool(axis is not None and native is not None and axis.nativeAcknowledged and
|
||||
axis.desiredLateral and axis.lateralActive and not axis.desiredLongitudinal and
|
||||
native.requestedLateral and not native.requestedLongitudinal and native.lateralAllowed)
|
||||
@@ -0,0 +1,30 @@
|
||||
def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source_ns, now_ns, axis_session_id, pending_lateral_request=False) -> bool:
|
||||
"""A fresh native-OFF baseline may arm LAT while the belt remains a LONG lock."""
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.aol.vehicle import policy_for
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
|
||||
try:
|
||||
pending_native = bool(pending_lateral_request and native is not None and
|
||||
not native.requestedLateral and not native.lateralAllowed)
|
||||
return bool(policy_for(cp).intent_supported and event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and
|
||||
event.noEntry and not event.immediateDisable and not event.userDisable and
|
||||
cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and
|
||||
not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and
|
||||
cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
|
||||
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and
|
||||
intent is not None and intent.settingsQualified and
|
||||
(intent.allowedLatch and intent.lateralArmed or
|
||||
pending_native and not intent.allowedLatch and not intent.lateralArmed) and
|
||||
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
|
||||
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - source_ns <= AXIS_MAX_AGE_NS and
|
||||
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
|
||||
native is not None and native.compatible and int(native.protocolVersion) == 1 and
|
||||
axis_session_id and native.axisSessionId == axis_session_id and
|
||||
(native.requestedLateral and native.lateralAllowed or pending_native) and native.pandaInventory and
|
||||
native_inventory_matches_cp(native, cp) and
|
||||
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and
|
||||
native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS)
|
||||
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
||||
return False
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Classify ordinary host source loss without granting commands or engagement."""
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import log
|
||||
from openpilot.selfdrive.selfdrived.events import ET
|
||||
from openpilot.starpilot.aol.intent_companion import MAX_COMPANIONS
|
||||
from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS, SAFETY_MAX_AGE_NS, current_native
|
||||
from openpilot.starpilot.aol.transport_pause import (cp_identity, healthy_transport, HOST_SOURCE_AVAILABILITY_EVENTS,
|
||||
host_source_availability_only, stock_lateral_off, stock_lateral_user_event)
|
||||
from openpilot.starpilot.nostalgia import physical_cancel
|
||||
|
||||
EventName = log.OnroadEvent.EventName
|
||||
AlertLevel = log.DriverMonitoringState.AlertLevel
|
||||
|
||||
|
||||
def stock_lateral_role(self, CS, native, intent=None):
|
||||
"""Current stock USER role while an established independent LAT owner remains."""
|
||||
rules = self.events.event_rules
|
||||
stock_user = stock_lateral_user_event(self.CP)
|
||||
users = [name for name in self.events.names if ET.USER_DISABLE in rules.get(name, {})]
|
||||
if stock_user is None or not users or any(name != stock_user or
|
||||
ET.SOFT_DISABLE in rules.get(name, {}) or ET.IMMEDIATE_DISABLE in rules.get(name, {}) for name in users):
|
||||
return False
|
||||
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
|
||||
if (anchor is None or not anchor[0][8] or anchor[0][7] or anchor[0][9] or self.enabled or self.active or
|
||||
self.state_machine.state != log.SelfdriveState.OpenpilotState.disabled or
|
||||
anchor[0][0] != self.aol_session_id or cp_identity(self.CP) != anchor[3] or
|
||||
getattr(self, 'aol_dm_lateral_inhibit', False) or
|
||||
not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or
|
||||
self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or
|
||||
self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or
|
||||
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or
|
||||
not stock_lateral_off(self.CP, CS, native)):
|
||||
return False
|
||||
accepted = intent if intent is not None else anchor[2]
|
||||
companion = getattr(self, 'aol_intent_companion', None)
|
||||
return bool(accepted is not None and accepted.settingsQualified and accepted.allowedLatch and accepted.lateralArmed and
|
||||
not accepted.pauseLateral and not accepted.pauseLongitudinal and
|
||||
accepted.producerSessionId == anchor[0][4] and companion is not None and companion.last_drain_ok and
|
||||
companion.integrity_epoch == anchor[0][5])
|
||||
|
||||
|
||||
def stock_lateral_fault_proof(self, CS, message, now_ns):
|
||||
"""Capture the stock role of this successfully published inactive fault."""
|
||||
published = self.aol_published_fault_proof
|
||||
intent = getattr(self, 'aol_last_intent', None)
|
||||
native = getattr(self, 'aol_decision_native', None)
|
||||
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
|
||||
axis = message.aolAxisState
|
||||
if (published is None or anchor is None or anchor[1] is None or not message.valid or not axis.qualified or published[1] != 'critical' or
|
||||
any(published[4:]) or not stock_lateral_role(self, CS, native, intent) or
|
||||
self.aol_authority_lost or getattr(self, 'aol_availability_long_inhibit', False) or
|
||||
getattr(self, 'car_state_queue_invalid', False) or getattr(self, 'aol_selected_pair_future', False) or
|
||||
not getattr(self, 'aol_step_intent_drain_ok', True) or
|
||||
getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or
|
||||
not getattr(self, 'conditional_car_state_valid', False) or intent != anchor[2] or CS is not anchor[4] or
|
||||
axis.sourceCarStateMonoTime != intent.carStateLogMonoTime or
|
||||
not 0 < axis.sourceCarStateMonoTime <= axis.observedMonoTime <= now_ns <= axis.validUntilMonoTime or
|
||||
now_ns - axis.sourceCarStateMonoTime > INTENT_MAX_AGE_NS or
|
||||
native is not getattr(self.aol_transport_pause, 'observed_native', None) or
|
||||
not 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime or
|
||||
native.observedMonoTime < anchor[1].observedMonoTime or native.pandaSerial != anchor[1].pandaSerial or
|
||||
tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in anchor[1].pandaInventory) or
|
||||
any(self.events.contains(kind) for kind in (ET.ENABLE, ET.OVERRIDE_LATERAL, ET.OVERRIDE_LONGITUDINAL))):
|
||||
return None
|
||||
events = self.events.to_msg()
|
||||
stock_user = stock_lateral_user_event(self.CP)
|
||||
host = [e for e in events if not (e.name == stock_user and e.userDisable and
|
||||
not e.softDisable and not e.immediateDisable)]
|
||||
if not host_source_availability_only(host) or not any(e.name == EventName.selfdrivedLagging for e in host):
|
||||
return None
|
||||
samples = getattr(self, 'car_state_samples', ()) or ((CS, True),)
|
||||
if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or
|
||||
sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or
|
||||
sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or
|
||||
sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport,
|
||||
car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples):
|
||||
return None
|
||||
return (published, stock_user)
|
||||
|
||||
|
||||
def ordinary_source_availability(self, CS, now_ns, native=None, *, replay: bool, native_provider=current_native):
|
||||
"""Classify lost host source; never renew a command or change engagement."""
|
||||
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
|
||||
companion = getattr(self, 'aol_intent_companion', None)
|
||||
events = self.events.to_msg()
|
||||
stock_user = None
|
||||
if any(event.name in (EventName.pcmDisable, EventName.cruiseDisabled) and event.userDisable for event in events):
|
||||
# Resolve the native value once, as this classifier already does below.
|
||||
# Normal healthy frames never enter this nominal stock-role branch.
|
||||
native = native or native_provider(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id)
|
||||
if stock_lateral_role(self, CS, native):
|
||||
stock_user = stock_lateral_user_event(self.CP)
|
||||
events = [event for event in events if not (event.name == stock_user and event.userDisable and
|
||||
not event.softDisable and not event.immediateDisable)]
|
||||
if (replay or not self.aol_replay or not self.axis_transport_required or anchor is None or
|
||||
not self.initialized or getattr(self, 'aol_availability_long_inhibit', False) or self.aol_authority_lost or
|
||||
getattr(self, 'aol_fault_reason', 'none') not in ('none', 'availabilityPause', 'critical') or
|
||||
any(event.userDisable or event.immediateDisable for event in events) or
|
||||
any(e.softDisable and e.name not in HOST_SOURCE_AVAILABILITY_EVENTS
|
||||
for e in events) or self.events.contains(ET.ENABLE) or
|
||||
getattr(self, 'car_state_queue_invalid', False) or
|
||||
not getattr(self, 'aol_step_intent_drain_ok', True) or
|
||||
getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or companion is None or not companion.last_drain_ok or
|
||||
getattr(self, 'aol_selected_pair_future', False) or
|
||||
not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or
|
||||
self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or
|
||||
self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or
|
||||
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout):
|
||||
return False
|
||||
proof, prior_native, accepted, identity, accepted_state = anchor
|
||||
if accepted is None:
|
||||
return False
|
||||
# A source admitted by normal runtime in THIS step can expire only while
|
||||
# publishing its first host-SOFT fault. It has no prior critical publication.
|
||||
# Require the exact admitted object/state and original completed baseline;
|
||||
# stale historical inputs cannot enter this path or grant a new command.
|
||||
admitted_expiry = bool(getattr(self, 'aol_published_fault_reason', '') == 'none' and
|
||||
getattr(self, 'aol_step_input_proof', None) == proof and
|
||||
getattr(self, 'aol_last_intent', None) is accepted and
|
||||
getattr(self, 'conditional_car_state_valid', False) and CS is accepted_state and
|
||||
self.aol_car_state_log_ns == accepted.carStateLogMonoTime and
|
||||
host_source_availability_only(events))
|
||||
if getattr(self, 'aol_fault_reason', 'none') == 'critical' and not admitted_expiry:
|
||||
# A prior OFF/critical proof may describe ordinary SOFT loop lag rather
|
||||
# than an authority fault. Prove that exact published episode; never infer
|
||||
# it from a current healthy native snapshot or clear authority_lost here.
|
||||
published = getattr(self, 'aol_published_fault_proof', None)
|
||||
if (published is None or len(published) != 9 or published[0] != self.aol_session_id or
|
||||
published[1] != 'critical' or published[2] != getattr(self, 'aol_fault_event_ns', 0) or
|
||||
published[3] != tuple(getattr(self, 'events_prev', ()))):
|
||||
return False
|
||||
prior_names = published[3]
|
||||
stock_record = getattr(self, 'aol_published_stock_lateral_proof', None)
|
||||
if (stock_user is not None and stock_record is not None and len(stock_record) == 2 and
|
||||
stock_record[0] == published and stock_record[1] == stock_user):
|
||||
# This USER was qualified at the actual completed prior publication,
|
||||
# not inferred from today's healthy physical/native snapshot.
|
||||
prior_names = tuple(name for name in prior_names if name != stock_user)
|
||||
rules = self.events.event_rules
|
||||
prior_faults = [name for name in prior_names if any(kind in rules.get(name, {})
|
||||
for kind in (ET.SOFT_DISABLE, ET.IMMEDIATE_DISABLE, ET.USER_DISABLE))]
|
||||
if (getattr(self, 'aol_published_fault_reason', '') != 'critical' or
|
||||
not getattr(self, 'aol_fault_event_ns', 0) or
|
||||
EventName.selfdrivedLagging not in prior_faults or
|
||||
not all(name in HOST_SOURCE_AVAILABILITY_EVENTS and
|
||||
not any(kind in rules.get(name, {}) for kind in (ET.IMMEDIATE_DISABLE, ET.USER_DISABLE))
|
||||
for name in prior_faults) or not host_source_availability_only(events) or
|
||||
any(published[4:])):
|
||||
return False
|
||||
source = self.aol_car_state_log_ns or getattr(self, 'car_state_retained_source_ns', 0)
|
||||
held_metadata = bool(not getattr(self, 'car_state_samples', ()) and CS is accepted_state and
|
||||
getattr(self, 'aol_publication_retained_intent', None) is accepted)
|
||||
if not source and held_metadata:
|
||||
source = int(accepted.carStateLogMonoTime)
|
||||
if (proof[0] != self.aol_session_id or not (proof[8] or proof[9]) or accepted is None or source != int(accepted.carStateLogMonoTime) or
|
||||
not 0 < source <= now_ns or now_ns - source <= INTENT_MAX_AGE_NS or
|
||||
cp_identity(self.CP) != identity or companion.integrity_epoch != proof[5] or
|
||||
accepted is None or accepted.producerSessionId != proof[4] or
|
||||
prior_native is None or not prior_native.pandaInventory):
|
||||
return False
|
||||
samples = getattr(self, 'car_state_samples', ()) or ((CS,
|
||||
bool(getattr(self, 'car_state_retained_valid', False) or held_metadata)),)
|
||||
if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or
|
||||
sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or
|
||||
sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or
|
||||
sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport,
|
||||
car.CarState.GearShifter.low, car.CarState.GearShifter.brake)
|
||||
for sample, valid in samples):
|
||||
return False
|
||||
def valid_metadata(stamp, value):
|
||||
if (value is None or value.carStateLogMonoTime != stamp or
|
||||
value.producerSessionId != proof[4] or not value.settingsQualified or
|
||||
value.allowedLatch != accepted.allowedLatch or value.lateralArmed != accepted.lateralArmed or
|
||||
value.pauseLongitudinal or value.pauseLateral and (proof[8] or not accepted.pauseLateral)):
|
||||
return False
|
||||
# Only the exact originally accepted tuple is identity after expiry.
|
||||
# Every newer raw/SM tuple keeps its own original shape and 200ms lease;
|
||||
# none supplies an active command or a renewed source/output deadline.
|
||||
return bool(stamp == accepted.carStateLogMonoTime and value == accepted or
|
||||
0 < stamp <= value.observedMonoTime <= now_ns <= value.validUntilMonoTime and
|
||||
0 < value.validUntilMonoTime - value.observedMonoTime <= SAFETY_MAX_AGE_NS)
|
||||
|
||||
# The raw-only cache retains every distinct tuple in a <=16-message step.
|
||||
# Its exact validated receipts remain metadata when current() legitimately
|
||||
# prunes the separate 30ms source-pair cache. Check every original restriction
|
||||
# directly, including one no longer present in the conflated SM/sample cache.
|
||||
accounted = 0
|
||||
for stamp, (value, epoch, producer) in companion.raw_projections.items():
|
||||
if stamp < accepted.carStateLogMonoTime:
|
||||
continue
|
||||
if epoch != proof[5] or producer != proof[4] or not valid_metadata(stamp, value):
|
||||
return False
|
||||
accounted += 1
|
||||
if accounted < getattr(self, 'aol_step_intent_drain_count', 0):
|
||||
return False
|
||||
# A newer conflated SM sample may add a restriction but cannot remove any
|
||||
# already checked raw restriction. Invalid/reordered bursts also retain the
|
||||
# existing integrity-epoch and cumulative-drain vetoes above.
|
||||
for stamp, valid, value in companion.samples.values():
|
||||
if stamp > accepted.carStateLogMonoTime and (not valid or not valid_metadata(stamp, value)):
|
||||
return False
|
||||
latest = companion.latest
|
||||
raw = latest[2] if latest is not None and latest[1] else None
|
||||
if (latest is None or not accepted.carStateLogMonoTime <= latest[0] <= now_ns or
|
||||
not valid_metadata(latest[0], raw)):
|
||||
return False
|
||||
native = native or native_provider(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id)
|
||||
lateral_intent_only = bool(proof[8] and not proof[9] and not self.enabled)
|
||||
if (native is None or lateral_intent_only and not (accepted.allowedLatch and accepted.lateralArmed and
|
||||
not accepted.pauseLateral and not getattr(self, 'aol_dm_lateral_inhibit', False) and
|
||||
native.requestedLateral and native.lateralAllowed) or
|
||||
not healthy_transport(self.sm, self.CP, CS, self.aol_session_id, now_ns, native) or
|
||||
native.observedMonoTime < prior_native.observedMonoTime or
|
||||
native.pandaSerial != prior_native.pandaSerial or
|
||||
tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in prior_native.pandaInventory)):
|
||||
return False
|
||||
self.aol_ordinary_source_native = native
|
||||
return True
|
||||
|
||||
|
||||
def fresh_ordinary_lag_continuation(self, CS, intent, native, now_ns):
|
||||
"""Continue established standard axes; native/inputs still own every grant."""
|
||||
if (EventName.selfdrivedLagging not in self.events.names or not self.enabled or not self.active or
|
||||
self.state_machine.state != log.SelfdriveState.OpenpilotState.softDisabling or
|
||||
not self.initialized or not self.aol_replay or not self.axis_transport_required or
|
||||
self.aol_authority_lost or getattr(self, 'aol_availability_long_inhibit', False)):
|
||||
return False
|
||||
rules = self.events.event_rules
|
||||
faults = [name for name in self.events.names if any(kind in rules.get(name, {})
|
||||
for kind in (ET.USER_DISABLE, ET.SOFT_DISABLE, ET.IMMEDIATE_DISABLE))]
|
||||
lag_rules = rules.get(EventName.selfdrivedLagging, {})
|
||||
if (faults != [EventName.selfdrivedLagging] or ET.SOFT_DISABLE not in lag_rules or
|
||||
ET.IMMEDIATE_DISABLE in lag_rules or ET.USER_DISABLE in lag_rules or self.events.contains(ET.ENABLE) or
|
||||
self.events.contains(ET.OVERRIDE_LATERAL) or self.events.contains(ET.OVERRIDE_LONGITUDINAL) or
|
||||
any(name != EventName.selfdrivedLagging and ET.NO_ENTRY in self.events.event_rules.get(name, {})
|
||||
for name in self.events.names)):
|
||||
return False
|
||||
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
|
||||
companion = getattr(self, 'aol_intent_companion', None)
|
||||
if (anchor is None or intent is None or companion is None or not (anchor[0][8] or anchor[0][9]) or
|
||||
anchor[0][0] != self.aol_session_id or companion.integrity_epoch != anchor[0][5] or
|
||||
intent != anchor[2] or CS is not anchor[4] or intent.producerSessionId != anchor[0][4] or
|
||||
cp_identity(self.CP) != anchor[3] or not getattr(self, 'conditional_car_state_valid', False) or
|
||||
not 0 < self.aol_car_state_log_ns == intent.carStateLogMonoTime <= now_ns or
|
||||
now_ns - self.aol_car_state_log_ns > INTENT_MAX_AGE_NS or
|
||||
getattr(self, 'car_state_queue_invalid', False) or getattr(self, 'aol_selected_pair_future', False) or
|
||||
not getattr(self, 'aol_step_intent_drain_ok', True) or not companion.last_drain_ok or
|
||||
getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or
|
||||
not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or
|
||||
self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or
|
||||
self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or
|
||||
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or
|
||||
native is None or native is not getattr(self.aol_transport_pause, 'observed_native', None) or
|
||||
native.observedMonoTime < anchor[1].observedMonoTime or native.pandaSerial != anchor[1].pandaSerial or
|
||||
tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in anchor[1].pandaInventory)):
|
||||
return False
|
||||
if intent.pauseLongitudinal or intent.pauseLateral and anchor[0][8]:
|
||||
return False
|
||||
for stamp, (value, epoch, producer) in companion.raw_projections.items():
|
||||
if stamp < anchor[0][1]:
|
||||
continue
|
||||
if (epoch != anchor[0][5] or producer != anchor[0][4] or value is None or
|
||||
value.producerSessionId != anchor[0][4] or not value.settingsQualified or
|
||||
value.allowedLatch != intent.allowedLatch or value.lateralArmed != intent.lateralArmed or
|
||||
value.pauseLongitudinal or value.pauseLateral and (anchor[0][8] or not intent.pauseLateral) or
|
||||
value.carStateLogMonoTime != stamp or not 0 < stamp <= value.observedMonoTime <= now_ns <= value.validUntilMonoTime or
|
||||
not 0 < value.validUntilMonoTime - value.observedMonoTime <= SAFETY_MAX_AGE_NS):
|
||||
return False
|
||||
samples = getattr(self, 'car_state_samples', ()) or ((CS, True),)
|
||||
return not any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or
|
||||
sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or
|
||||
sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or
|
||||
sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport,
|
||||
car.CarState.GearShifter.low, car.CarState.GearShifter.brake)
|
||||
for sample, valid in samples)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Synthetic parsed CAN and native axis/controller projection, not vehicle replay."""
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from opendbc.can import CANPacker, CANParser
|
||||
from opendbc.car import Bus, structs
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
|
||||
from opendbc.car.vehicle_model import VehicleModel
|
||||
from opendbc.safety.tests.common import make_msg
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from opendbc.safety.tests.test_hyundai import checksum
|
||||
from openpilot.starpilot.aol.tests.test_forte_intent import params, settings
|
||||
from openpilot.starpilot.car.hyundai.aol import create_intent, policy_for
|
||||
from openpilot.starpilot.aol.runtime import current_native, decide_axes
|
||||
from openpilot.starpilot.aol.wire import SAFETY_SERVICE, SafetyState, encode_safety
|
||||
from openpilot.cereal import log
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
|
||||
from openpilot.starpilot.lateral.controller_selection import ControllerMode
|
||||
|
||||
|
||||
class Samples(dict):
|
||||
def __init__(self, native, stamp):
|
||||
super().__init__({SAFETY_SERVICE: encode_safety(native)})
|
||||
self.valid = self.alive = self.seen = {SAFETY_SERVICE: True}
|
||||
self.logMonoTime = {SAFETY_SERVICE: stamp}
|
||||
|
||||
|
||||
class ForteStream:
|
||||
def __init__(self, *, main_action=0, source=0x391, aol=True, car=CAR.KIA_FORTE_2019_NON_SCC, controller_mode=None):
|
||||
self.source = source
|
||||
self.cp = params(car, source=source)
|
||||
policy = policy_for(self.cp)
|
||||
if aol:
|
||||
self.cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition
|
||||
self.cp.alternativeExperience |= policy.alternative_experience_addition
|
||||
self.ci = CarInterface(self.cp)
|
||||
self.ci.update([])
|
||||
self.packer = CANPacker(DBC[self.cp.carFingerprint][Bus.pt])
|
||||
self.output_parser = CANParser(DBC[self.cp.carFingerprint][Bus.pt], [('LKAS11', 100)], 0)
|
||||
self.lateral_controller = (LatControlTorque(self.cp.as_reader(), self.ci, .01, controller_mode=controller_mode)
|
||||
if controller_mode is not None else None)
|
||||
self.vehicle_model = VehicleModel(self.cp)
|
||||
self.vehicle_parameters = log.VehicleParameters.new_message(angleOffsetDeg=0., roll=0.)
|
||||
self.desired_curvature = 0.
|
||||
self.last_torque = 0.
|
||||
self.last_normalized_torque = 0.
|
||||
self.intent = create_intent(self.cp, settings(main=main_action))
|
||||
self.intent.settings = replace(self.intent.settings, enabled=aol)
|
||||
self.safety = libsafety_py.libsafety # Harness preloads the qualified candidate.
|
||||
self.now = 1_000_000_000
|
||||
self.count = 0
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.safety.init_tests()
|
||||
self.safety.set_alternative_experience(self.cp.alternativeExperience)
|
||||
assert self.safety.set_safety_hooks(int(self.cp.safetyConfigs[0].safetyModel.raw),
|
||||
self.cp.safetyConfigs[0].safetyParam) == 0
|
||||
self.safety.set_timer(self.now // 1000)
|
||||
self.safety.set_aol_test_heartbeat(True) # Synthetic transport envelope only.
|
||||
|
||||
def tick(self, *, main=False, cruise=False, bcm=False, clu=False, gear='D', eps=False,
|
||||
fatal=False, rejection=0, cruise_button=0):
|
||||
self.now += 10_000_000
|
||||
n = self.count
|
||||
self.count += 1
|
||||
gears = self.ci.CS.shifter_values
|
||||
gear_code = next(code for code, name in gears.items() if name == gear)
|
||||
values: dict[str, dict[str, float]] = {
|
||||
'EMS16': {'CRUISE_LAMP_M': int(main), 'CRUISE_LAMP_S': int(cruise), 'AliveCounter': n % 4},
|
||||
'WHL_SPD11': {'WHL_SPD_FL': 72, 'WHL_SPD_FR': 72, 'WHL_SPD_RL': 72, 'WHL_SPD_RR': 72,
|
||||
'WHL_SPD_AliveCounter_LSB': n % 4, 'WHL_SPD_AliveCounter_MSB': (n // 4) % 4},
|
||||
'TCS13': {'AliveCounterTCS': n % 8},
|
||||
'MDPS12': {'CF_Mdps_ToiUnavail': int(eps), 'CR_Mdps_StrColTq': 0., 'CR_Mdps_OutTq': 0.},
|
||||
'SAS11': {'SAS_Angle': 0., 'SAS_Speed': 0.},
|
||||
'CLU11': {'CF_Clu_AliveCnt1': n % 16, 'CF_Clu_CruiseSwState': cruise_button}, 'LVR12': {'CF_Lvr_Gear': gear_code},
|
||||
'BCM_PO_11': {'LDA_BTN': int(bcm)}, 'CLU13': {'CF_Clu_LdwsLkasSW': int(clu)},
|
||||
'CGW1': {'CF_Gway_DrvSeatBeltSw': 1},
|
||||
}
|
||||
if self.source == 0x50c:
|
||||
del values['BCM_PO_11']
|
||||
frames = []
|
||||
for name in ('EMS12', 'TCS11', 'TCS15', 'CLU15', 'ESP12', 'CGW2', *values):
|
||||
frame = self.packer.make_can_msg(name, 0, values.get(name, {}))
|
||||
if name in ('EMS16', 'WHL_SPD11', 'TCS13'):
|
||||
frame = checksum(frame)
|
||||
frames.append(frame)
|
||||
frames.append(self.packer.make_can_msg('LKAS11', 2, {'CF_Lkas_MsgCount': n % 16}))
|
||||
if not self.cp.flags & HyundaiFlags.NON_SCC_NO_FCA:
|
||||
frames.append(self.packer.make_can_msg('FCA11', 2, {}))
|
||||
self.safety.set_timer(self.now // 1000)
|
||||
for address, data, bus in frames:
|
||||
self.safety.safety_rx_hook(make_msg(bus, address, len(data), data))
|
||||
self.safety.safety_tick()
|
||||
with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \
|
||||
patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=self.now):
|
||||
self.cs = self.ci.update([(self.now, frames)])
|
||||
self.intent.update(self.cs, now_ns=self.now, fault_active=fatal, native_rejection_ns=rejection)
|
||||
return self.axis()
|
||||
|
||||
def axis(self, receipt=None):
|
||||
intent = SimpleNamespace(allowedLatch=self.intent.allowed_latch, pauseLateral=self.intent.pause_lateral,
|
||||
pauseLongitudinal=self.intent.pause_longitudinal)
|
||||
kwargs: dict = {'standard_lateral': False, 'standard_longitudinal': False, 'intent': intent,
|
||||
'car_state': self.cs, 'initialized': True, 'model_ready': True, 'no_entry': False,
|
||||
'immediate_disable': False, 'dm_lockout': False, 'pause_brake_mps': 0.}
|
||||
desired = decide_axes(native=None, **kwargs)
|
||||
self.safety.aol_set_host_request(int(desired.desired_lateral))
|
||||
permission = self.safety.aol_get_permission_mask()
|
||||
native = SafetyState(1, True, self.now, self.now + 30_000_000,
|
||||
int(self.cp.safetyConfigs[0].safetyModel.raw), self.cp.safetyConfigs[0].safetyParam,
|
||||
bool(permission & 1), False, bool(self.safety.aol_get_request_mask() & 1), False, 'synthetic-panda', 'session')
|
||||
if receipt is not None:
|
||||
native = receipt(native)
|
||||
acknowledged = current_native(Samples(native, self.now), self.cp, now_ns=self.now, axis_session_id='session')
|
||||
decision = decide_axes(native=acknowledged, **kwargs)
|
||||
command = structs.CarControl()
|
||||
command.latActive = decision.lateral_active
|
||||
if self.lateral_controller is not None:
|
||||
torque, _, _ = self.lateral_controller.update(
|
||||
decision.lateral_active, self.cs, self.vehicle_model, self.vehicle_parameters, False,
|
||||
self.desired_curvature, False, self.cp.steerActuatorDelay)
|
||||
command.actuators.torque = float(torque)
|
||||
else:
|
||||
command.actuators.torque = 0.01 if decision.lateral_active else 0.
|
||||
self.last_normalized_torque = float(command.actuators.torque)
|
||||
_, packets = self.ci.apply(command.as_reader(), self.now)
|
||||
lkas = [packet for packet in packets if packet[0] == 0x340]
|
||||
assert lkas, 'Actual controller must emit LKAS'
|
||||
self.output_parser.update((self.now, lkas))
|
||||
self.last_torque = self.output_parser.vl['LKAS11']['CR_Lkas_StrToqReq']
|
||||
for address, data, bus in lkas:
|
||||
assert self.safety.safety_tx_hook(make_msg(bus, address, len(data), data)), 'Actual LKAS rejected'
|
||||
return decision
|
||||
|
||||
|
||||
class TestForteIntegration(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
libsafety_py.libsafety.set_alternative_experience(0)
|
||||
|
||||
def warm(self, stream, **kwargs):
|
||||
for _ in range(12):
|
||||
result = stream.tick(**kwargs)
|
||||
self.assertTrue(stream.cs.canValid)
|
||||
return result
|
||||
|
||||
def test_both_forte_controllers_reach_native_with_original_torque_limit(self):
|
||||
for car in (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC):
|
||||
for source in (0, 0x391):
|
||||
for mode in (ControllerMode.STANDARD, ControllerMode.STARPILOT):
|
||||
for direction in (-1., 1.):
|
||||
with self.subTest(car=car, source=source, mode=mode, direction=direction):
|
||||
case = f'{car}, source={source:#x}, controller={mode}, direction={direction}'
|
||||
stream = ForteStream(car=car, source=source, main_action=9, controller_mode=mode)
|
||||
self.assertEqual(stream.cp.safetyConfigs[0].safetyParam, 0x1c00 if source else 0x1400)
|
||||
self.assertEqual(stream.cp.alternativeExperience, 32)
|
||||
self.assertEqual(stream.lateral_controller.controller_mode, mode)
|
||||
self.assertEqual(bool(stream.lateral_controller.starpilot_extension), mode == ControllerMode.STARPILOT)
|
||||
self.assertTrue(self.warm(stream, main=True).lateral_active, case)
|
||||
self.assertGreater(stream.cs.vEgo, 19., case)
|
||||
self.assertEqual(stream.cs.steeringAngleDeg, 0., case)
|
||||
self.assertEqual(stream.cs.steeringTorque, 0., case)
|
||||
self.assertFalse(stream.cs.steeringPressed, case)
|
||||
stream.desired_curvature = direction * .01
|
||||
for _ in range(180):
|
||||
self.assertTrue(stream.tick(main=True).lateral_active, case)
|
||||
self.assertLessEqual(abs(stream.last_torque), 255, case)
|
||||
context = (f'{case}; vEgo={stream.cs.vEgo}, angle={stream.cs.steeringAngleDeg}, ' +
|
||||
f'driverTorque={stream.cs.steeringTorque}, normalized={stream.last_normalized_torque}, ' +
|
||||
f'sentTorque={stream.last_torque}, factor={stream.lateral_controller.torque_params.latAccelFactor}')
|
||||
self.assertGreater(abs(stream.last_torque), 200, context)
|
||||
self.assertLess(stream.last_torque * direction, 0., context)
|
||||
if mode == ControllerMode.STANDARD:
|
||||
self.assertEqual(stream.last_torque, -direction * 255, context)
|
||||
self.assertFalse(stream.tick(main=False).lateral_active, case)
|
||||
self.assertEqual(stream.last_torque, 0, case)
|
||||
|
||||
def test_native_reset_requires_neutral_and_new_physical_gesture(self):
|
||||
s = ForteStream()
|
||||
self.warm(s)
|
||||
self.assertTrue(s.tick(bcm=True).lateral_active)
|
||||
s.reset()
|
||||
self.assertFalse(self.warm(s, bcm=True).lateral_active)
|
||||
s.tick(bcm=True, rejection=s.now + 10_000_000)
|
||||
self.assertFalse(s.tick(bcm=True).lateral_active)
|
||||
s.tick()
|
||||
self.assertTrue(s.tick(bcm=True).lateral_active)
|
||||
|
||||
def test_main_boot_both_button_orders_and_combined_sources(self):
|
||||
s = ForteStream(main_action=9)
|
||||
self.assertTrue(self.warm(s, main=True).lateral_active)
|
||||
self.assertFalse(s.tick(main=True, cruise=True, bcm=True).lateral_active)
|
||||
s.tick(main=True, cruise=True)
|
||||
self.assertTrue(s.tick(main=True, cruise=True, clu=True).lateral_active)
|
||||
t = ForteStream()
|
||||
self.warm(t)
|
||||
self.assertTrue(t.tick(bcm=True).lateral_active)
|
||||
self.assertTrue(t.tick(bcm=True, clu=False).lateral_active)
|
||||
self.assertTrue(t.tick(bcm=True, clu=False).lateral_active)
|
||||
t.tick()
|
||||
self.assertFalse(t.tick(clu=True).lateral_active)
|
||||
|
||||
def test_wrong_session_stale_receipt_and_output_pause(self):
|
||||
s = ForteStream(main_action=9)
|
||||
self.assertTrue(self.warm(s, main=True).lateral_active)
|
||||
self.assertFalse(s.axis(lambda n: replace(n, axisSessionId='old')).lateral_active)
|
||||
self.assertFalse(s.axis(lambda n: replace(n, validUntilMonoTime=s.now - 1)).lateral_active)
|
||||
self.assertFalse(s.tick(main=True, eps=True).lateral_active)
|
||||
self.assertTrue(s.intent.allowed_latch)
|
||||
self.assertFalse(s.tick(main=True, gear='R').lateral_active)
|
||||
self.assertTrue(s.tick(main=True).lateral_active)
|
||||
self.assertFalse(s.tick(main=True, fatal=True).lateral_active)
|
||||
self.assertFalse(s.tick(main=True).lateral_active)
|
||||
s.tick(main=False)
|
||||
self.assertTrue(s.tick(main=True).lateral_active)
|
||||
|
||||
def test_50c_only_ordinary_profile_does_not_require_absent_bcm(self):
|
||||
s = ForteStream(source=0x50c, aol=False)
|
||||
self.assertEqual(s.cp.safetyConfigs[0].safetyParam, 0x1000)
|
||||
self.assertEqual(s.cp.alternativeExperience, 0)
|
||||
self.assertFalse(self.warm(s, main=True).lateral_active)
|
||||
s.tick(main=True, cruise_button=2) # Actual SET interaction precedes cruise rising.
|
||||
self.assertFalse(s.tick(main=True, cruise=True).lateral_active)
|
||||
self.assertTrue(s.cs.cruiseState.available)
|
||||
self.assertTrue(s.cs.cruiseState.enabled)
|
||||
self.assertTrue(s.safety.get_controls_allowed())
|
||||
self.assertEqual(s.safety.aol_get_permission_mask(), 0)
|
||||
|
||||
def test_invalid_main_off_cannot_rearm_and_old_denial_preserves_new_edge(self):
|
||||
s = ForteStream(main_action=9)
|
||||
self.warm(s, main=True)
|
||||
s.tick(main=True, fatal=True)
|
||||
# A malformed/unhealthy state envelope is not confirmed main-OFF evidence.
|
||||
# Parsed CAN remains the source; the health failure is explicitly injected.
|
||||
s.cs.canValid = False
|
||||
s.cs.cruiseState.available = False
|
||||
s.intent.update(s.cs, now_ns=s.now + 1, fault_active=False)
|
||||
self.assertFalse(s.tick(main=True).lateral_active)
|
||||
s.tick(main=False)
|
||||
self.assertTrue(s.tick(main=True).lateral_active)
|
||||
t = ForteStream()
|
||||
self.warm(t)
|
||||
old_denial = t.now
|
||||
self.assertTrue(t.tick(bcm=True).lateral_active)
|
||||
self.assertTrue(t.tick(bcm=True, rejection=old_denial).lateral_active)
|
||||
|
||||
def test_unselected_clu_source_grants_only_after_its_own_fresh_neutral(self):
|
||||
s = ForteStream()
|
||||
# BCM arrives first and remains the required native-health alternative.
|
||||
# CLU's independent neutral and press must still reach the port owner.
|
||||
self.warm(s)
|
||||
self.assertFalse(s.safety.get_controls_allowed())
|
||||
self.assertTrue(s.tick(clu=True).lateral_active)
|
||||
self.assertTrue(s.intent.allowed_latch)
|
||||
self.assertEqual(s.safety.aol_get_permission_mask(), 1)
|
||||
self.assertFalse(s.safety.get_controls_allowed())
|
||||
self.assertTrue(s.tick(clu=True, bcm=False).lateral_active)
|
||||
s.tick()
|
||||
self.assertFalse(s.tick(clu=True).lateral_active)
|
||||
@@ -0,0 +1,223 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import Bus, gen_empty_fingerprint, structs
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.forte_aol import FORTE_IDS, FORTE_AOL_MARKER, FORTE_AOL_EXPERIENCE, ForteLkasSources
|
||||
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
|
||||
from openpilot.starpilot.aol.intent import AolSettings
|
||||
from openpilot.starpilot.aol.runtime import decide_axes
|
||||
from openpilot.starpilot.car.hyundai.aol import policy_for, create_intent, native_profile_supported, native_accepts_cp
|
||||
from types import SimpleNamespace
|
||||
|
||||
Button = structs.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def params(car=CAR.KIA_FORTE_2019_NON_SCC, source=0x391):
|
||||
fp = gen_empty_fingerprint()
|
||||
if source:
|
||||
fp[0][source] = 8
|
||||
return CarInterface.get_params(car, fp, [], False, False, False)
|
||||
|
||||
|
||||
def settings(lkas=9, main=0):
|
||||
return AolSettings(True, 0., lkas, main, (0, 0, 0), (0, 0, 0))
|
||||
|
||||
|
||||
def state(main=False, cruise=False, events=()):
|
||||
cs = structs.CarState()
|
||||
cs.canValid = True
|
||||
cs.gearShifter = 'drive'
|
||||
cs.cruiseState.available, cs.cruiseState.enabled = main, cruise
|
||||
cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=pressed) for button, pressed in events]
|
||||
return cs
|
||||
|
||||
|
||||
class TestForteIntent(unittest.TestCase):
|
||||
def test_actual_cp_profile_additions_and_exact_family_isolation(self):
|
||||
for car in FORTE_IDS:
|
||||
for source in (0, 0x391, 0x50c):
|
||||
cp = params(car, source)
|
||||
policy = policy_for(cp)
|
||||
self.assertTrue(policy.intent_supported)
|
||||
self.assertEqual(policy.safety_param_addition, FORTE_AOL_MARKER | (0x800 if source else 0))
|
||||
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x1800 if source == 0x391 else 0x1000)
|
||||
self.assertEqual(policy.alternative_experience_addition, FORTE_AOL_EXPERIENCE)
|
||||
cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition
|
||||
cp.alternativeExperience |= policy.alternative_experience_addition
|
||||
self.assertTrue(native_profile_supported(int(cp.safetyConfigs[0].safetyModel.raw), cp.safetyConfigs[0].safetyParam))
|
||||
self.assertTrue(native_accepts_cp(cp, int(cp.safetyConfigs[0].safetyModel.raw), cp.safetyConfigs[0].safetyParam))
|
||||
bad = cp.as_reader().as_builder()
|
||||
bad.safetyConfigs[0].safetyParam ^= 0x800
|
||||
self.assertFalse(policy_for(bad).intent_supported)
|
||||
for flag in (HyundaiFlags.LEGACY, HyundaiFlags.CAMERA_SCC, HyundaiFlags.CANFD, HyundaiFlags.FCEV, HyundaiFlags.CCNC):
|
||||
bad = cp.as_reader().as_builder()
|
||||
bad.flags |= int(flag)
|
||||
self.assertFalse(policy_for(bad).intent_supported)
|
||||
for field in ('passive', 'dashcamOnly', 'notCar'):
|
||||
bad = cp.as_reader().as_builder()
|
||||
setattr(bad, field, True)
|
||||
self.assertFalse(policy_for(bad).intent_supported)
|
||||
from opendbc.car.hyundai.non_scc_aol import qualified
|
||||
self.assertFalse(qualified(params(CAR.KIA_FORTE)))
|
||||
self.assertFalse(native_profile_supported(int(structs.CarParams.SafetyModel.hyundai), 0x1000))
|
||||
|
||||
def test_main_boot_and_assignment_defaults_and_symmetric_combined_pause(self):
|
||||
for car in FORTE_IDS:
|
||||
managed = create_intent(params(car), settings(main=9))
|
||||
managed.update(state(main=True), now_ns=1)
|
||||
self.assertTrue(managed.allowed_latch)
|
||||
managed.update(state(main=True, cruise=True, events=((Button.lkas, False),)), now_ns=2)
|
||||
for tick in (3, 5):
|
||||
managed.update(state(main=True, cruise=True, events=((Button.lkas, True),)), now_ns=tick)
|
||||
self.assertEqual(managed.pause_lateral, tick == 3)
|
||||
self.assertTrue(managed.allowed_latch)
|
||||
managed.update(state(main=True, cruise=True, events=((Button.lkas, False),)), now_ns=tick + 1)
|
||||
for source, expected in ((0, True), (0x391, False), (0x50c, False)):
|
||||
implicit = create_intent(params(car, source), settings(lkas=0))
|
||||
implicit.update(state(main=True), now_ns=1)
|
||||
self.assertEqual(implicit.allowed_latch, expected)
|
||||
managed.settings = settings(lkas=0, main=0)
|
||||
managed.update(state(main=True), now_ns=8)
|
||||
self.assertFalse(managed.allowed_latch)
|
||||
managed.settings = settings(lkas=0, main=9)
|
||||
managed.update(state(main=True), now_ns=9)
|
||||
self.assertTrue(managed.allowed_latch)
|
||||
|
||||
def test_lkas_native_denial_fatal_rearm_and_temporary_output_pause(self):
|
||||
intent = create_intent(params(), settings())
|
||||
intent.update(state(events=((Button.lkas, False),)), now_ns=1)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=2)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
cs = state()
|
||||
cs.steerFaultTemporary = True
|
||||
intent.update(cs, now_ns=3)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
cs.gearShifter = 'reverse'
|
||||
intent.update(cs, now_ns=4)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
self.assertFalse(intent.output(cs)[0])
|
||||
intent.update(state(), now_ns=5, native_rejection_ns=5)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=6)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
intent.update(state(events=((Button.lkas, False),)), now_ns=7)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=8)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
intent.update(state(), now_ns=9, fault_active=True)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
intent.update(state(), now_ns=10)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
|
||||
intent.update(state(events=((Button.lkas, False),)), now_ns=11)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=20)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
intent.update(state(), now_ns=21, native_rejection_ns=19)
|
||||
self.assertTrue(intent.allowed_latch) # Older denial cannot revoke a newer physical gesture.
|
||||
managed = create_intent(params(), settings(main=9))
|
||||
managed.update(state(main=True), now_ns=1)
|
||||
managed.update(state(main=True), now_ns=2, fault_active=True)
|
||||
bad = state(main=False)
|
||||
bad.canValid = False
|
||||
managed.update(bad, now_ns=3)
|
||||
managed.update(state(main=True), now_ns=4)
|
||||
self.assertFalse(managed.allowed_latch)
|
||||
managed.settings = settings(lkas=0, main=9)
|
||||
managed.update(state(main=True, events=((Button.lkas, False),)), now_ns=5)
|
||||
managed.update(state(main=True, events=((Button.lkas, True),)), now_ns=6)
|
||||
self.assertFalse(managed.allowed_latch)
|
||||
managed.update(state(main=False), now_ns=7)
|
||||
managed.update(state(main=True), now_ns=8)
|
||||
self.assertTrue(managed.allowed_latch)
|
||||
|
||||
def test_transient_fault_preserves_released_input_without_replaying_held_input(self):
|
||||
for fault in ('invalid', 'fatal', 'rejected'):
|
||||
for held in (False, True):
|
||||
with self.subTest(fault=fault, held=held):
|
||||
intent = create_intent(params(), settings())
|
||||
intent.update(state(events=((Button.lkas, False),)), now_ns=1)
|
||||
if held:
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=2)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
failed = state()
|
||||
failed.canValid = fault != 'invalid'
|
||||
intent.update(failed, now_ns=3, fault_active=fault == 'fatal', native_rejection_ns=3 if fault == 'rejected' else 0)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
intent.update(state(), now_ns=4, fault_active=False)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=5, fault_active=False)
|
||||
self.assertEqual(intent.allowed_latch, not held)
|
||||
if held:
|
||||
intent.update(state(events=((Button.lkas, False),)), now_ns=6, fault_active=False)
|
||||
intent.update(state(events=((Button.lkas, True),)), now_ns=7, fault_active=False)
|
||||
self.assertTrue(intent.allowed_latch)
|
||||
|
||||
def test_actual_ci_ordered_physical_or_and_held_initialization(self):
|
||||
for car in FORTE_IDS:
|
||||
cp = params(car)
|
||||
ci = CarInterface(cp)
|
||||
packer = CANPacker(DBC[car][Bus.pt])
|
||||
ci.update([])
|
||||
def packet(address, pressed, packer=packer):
|
||||
return packer.make_can_msg('BCM_PO_11' if address == 0x391 else 'CLU13', 0,
|
||||
{'LDA_BTN' if address == 0x391 else 'CF_Clu_LdwsLkasSW': int(pressed)})
|
||||
clock = 1_000_000_000
|
||||
def update(samples, ci=ci, packet=packet):
|
||||
nonlocal clock
|
||||
packets = []
|
||||
for address, pressed in samples:
|
||||
clock += 1_000_000
|
||||
packets.append((clock, [packet(address, pressed)]))
|
||||
with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \
|
||||
patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=clock):
|
||||
ret = ci.update(packets)
|
||||
return [event.pressed for event in ret.buttonEvents if event.type == Button.lkas]
|
||||
self.assertEqual(update(((0x391, True), (0x50c, False))), [])
|
||||
self.assertEqual(update(((0x391, False),)), [False])
|
||||
self.assertEqual(update(((0x391, True), (0x50c, False), (0x391, True))), [True])
|
||||
self.assertEqual(update(((0x391, False),)), [False])
|
||||
self.assertEqual(update(((0x50c, True), (0x50c, False))), [True, False])
|
||||
clock += 300_000_001
|
||||
self.assertEqual(update(((0x391, True),)), [])
|
||||
self.assertEqual(update(((0x391, True),)), [])
|
||||
self.assertEqual(update(((0x391, False), (0x391, True), (0x391, False))), [False, True, False])
|
||||
|
||||
def test_new_or_expired_source_must_show_own_neutral_without_phantom_edges(self):
|
||||
owner = ForteLkasSources()
|
||||
clock = 1_000_000_000
|
||||
def update(samples):
|
||||
nonlocal clock
|
||||
packets = []
|
||||
for address, pressed in samples:
|
||||
clock += 1_000_000
|
||||
data = bytearray(8)
|
||||
data[0 if address == 0x391 else 7] = 0x10 if address == 0x391 and pressed else int(pressed)
|
||||
packets.append((clock, [(address, bytes(data), 0)]))
|
||||
with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \
|
||||
patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=clock):
|
||||
owner.update(packets)
|
||||
return owner.edges
|
||||
self.assertEqual(update(((0x391, False),)), [False])
|
||||
self.assertEqual(update(((0x50c, True),)), [])
|
||||
self.assertEqual(update(((0x50c, False),)), [False])
|
||||
self.assertEqual(update(((0x50c, True), (0x50c, False))), [True, False])
|
||||
self.assertEqual(update(((0x391, True),)), [True])
|
||||
clock += 300_000_001 # Lost release: expiry cannot fabricate a replacement gesture.
|
||||
self.assertEqual(update(((0x391, True), (0x50c, False))), [])
|
||||
self.assertEqual(update(((0x391, False), (0x391, True))), [False, True])
|
||||
|
||||
def test_unacknowledged_native_denial_and_wrong_request_remain_neutral(self):
|
||||
cs = state(main=True)
|
||||
intent = SimpleNamespace(pauseLateral=False, pauseLongitudinal=False, allowedLatch=True)
|
||||
common: dict = {'standard_lateral': False, 'standard_longitudinal': False, 'intent': intent, 'car_state': cs,
|
||||
'initialized': True, 'model_ready': True, 'no_entry': False, 'immediate_disable': False, 'dm_lockout': False, 'pause_brake_mps': 0.}
|
||||
for native in (None, SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=False),
|
||||
SimpleNamespace(requestedLateral=False, requestedLongitudinal=False, lateralAllowed=True)):
|
||||
result = decide_axes(native=native, **common)
|
||||
self.assertTrue(result.desired_lateral)
|
||||
self.assertFalse(result.lateral_active)
|
||||
native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=True)
|
||||
self.assertTrue(decide_axes(native=native, **common).lateral_active)
|
||||
cs.steerFaultTemporary = True
|
||||
self.assertFalse(decide_axes(native=native, **common).lateral_active)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Independent axes use the finalized ordinary ASCM vehicle owner."""
|
||||
|
||||
import unittest
|
||||
|
||||
from opendbc.car.gm.aol import qualified_gm
|
||||
from opendbc.car.gm.tests.test_ascm_intercept import params
|
||||
from opendbc.car.gm.values import ORDINARY_ASCM_CAR
|
||||
from openpilot.starpilot.aol.vehicle import policy_for, create_intent
|
||||
from openpilot.starpilot.aol.intent import AolSettings
|
||||
|
||||
|
||||
class TestGmAscmAol(unittest.TestCase):
|
||||
def test_actual_finalized_cp_and_default_off(self):
|
||||
settings = AolSettings(False, 0., 0, 0, (0, 0, 0), (0, 0, 0))
|
||||
for identity in ORDINARY_ASCM_CAR:
|
||||
for release in (False, True):
|
||||
for alpha in (False, True):
|
||||
for c9 in (False, True):
|
||||
for radar in (False, True):
|
||||
cp = params(identity, sascm=True, alpha=alpha, release=release, accelerator=not c9, radar=radar)
|
||||
self.assertTrue(qualified_gm(cp))
|
||||
self.assertEqual(cp.alternativeExperience, 0)
|
||||
policy = policy_for(cp)
|
||||
self.assertTrue(policy.normal_runtime_supported)
|
||||
intent = create_intent(cp, settings, policy)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
cp.alternativeExperience = 32
|
||||
self.assertTrue(qualified_gm(cp))
|
||||
cp.alternativeExperience = 33
|
||||
self.assertFalse(qualified_gm(cp))
|
||||
|
||||
def test_profile_integrity_and_finite_tune_boundaries(self):
|
||||
for identity in ORDINARY_ASCM_CAR:
|
||||
cp = params(identity, sascm=True)
|
||||
for field in ('passive', 'dashcamOnly', 'notCar'):
|
||||
setattr(cp, field, True)
|
||||
self.assertFalse(qualified_gm(cp))
|
||||
setattr(cp, field, False)
|
||||
cp.safetyConfigs[0].safetyParam |= 4
|
||||
self.assertFalse(qualified_gm(cp))
|
||||
cp = params(identity, sascm=True)
|
||||
if cp.lateralTuning.which() == 'torque':
|
||||
cp.lateralTuning.torque.friction = float('nan')
|
||||
else:
|
||||
cp.lateralTuning.pid.kpV = [float('nan')]
|
||||
self.assertFalse(qualified_gm(cp))
|
||||
|
||||
def test_actual_controller_cruise_off_lateral_only_and_default_off(self):
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import Bus, structs
|
||||
from opendbc.car.gm.interface import CarInterface
|
||||
from opendbc.car.gm.tests.test_volt_camera_control import feed_camera
|
||||
from opendbc.car.gm.values import DBC
|
||||
for identity in ORDINARY_ASCM_CAR:
|
||||
for alpha in (False, True):
|
||||
for alternative in (0, 32):
|
||||
cp = params(identity, sascm=True, alpha=alpha)
|
||||
cp.alternativeExperience = alternative
|
||||
ci = CarInterface(cp)
|
||||
packer = CANPacker(DBC[identity][Bus.pt])
|
||||
observed = []
|
||||
for tick in range(6):
|
||||
now = 1_000_000_000 + tick * 40_000_000
|
||||
out, sources = feed_camera(ci, packer, now, counter=tick % 4, active=False)
|
||||
sources = [packet for packet in sources if packet[0] != 0x370]
|
||||
sources.append(packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCruiseState': 0}))
|
||||
out = ci.update([(now + 1, sources)])
|
||||
now += 1
|
||||
self.assertTrue(out.canValid)
|
||||
self.assertTrue(out.cruiseState.available)
|
||||
self.assertFalse(out.cruiseState.enabled)
|
||||
command = structs.CarControl(enabled=False, latActive=alternative == 32, longActive=False)
|
||||
command.actuators.torque = .1
|
||||
_, packets = ci.apply(command.as_reader(), now)
|
||||
observed.extend(packet for packet in packets if packet[0] == 0x180)
|
||||
self.assertTrue(observed)
|
||||
nonneutral = any(((packet[1][0] & 7) << 8 | packet[1][1]) != 0 for packet in observed)
|
||||
self.assertEqual(nonneutral, alternative == 32)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Exercise the real cross-socket wakeup between card and selfdrived."""
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.car.card import Car
|
||||
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
|
||||
from openpilot.starpilot.aol.runtime import current_intent, current_native, decide_axes
|
||||
from openpilot.starpilot.aol.wire import IntentState, SafetyState, encode_intent, encode_safety
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pause_lateral,qualified,expected', [(False, True, 'combined'), (True, True, 'longitudinalOnly'),
|
||||
(False, False, 'off')])
|
||||
def test_card_companion_is_available_when_car_state_wakes_selfdrive(pause_lateral, qualified, expected):
|
||||
with OpenpilotPrefix():
|
||||
messaging.reset_context()
|
||||
pm = messaging.PubMaster(['carState', 'carOutput', 'aolIntentWire', 'aolSafetyWire'])
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.sm = messaging.SubMaster(['aolIntentWire', 'aolSafetyWire'])
|
||||
sd.car_state_sock = messaging.sub_sock('carState', timeout=100)
|
||||
sd.initialized, sd.enabled = True, False
|
||||
sd.aol_car_state_log_ns, sd.conditional_car_state_valid = 0, False
|
||||
sd.CS_prev = car.CarState.new_message(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.)
|
||||
stamp = time.monotonic_ns()
|
||||
prior = stamp - 29_350_000 # Route: 29 ms CAN gap plus 2.7 ms consumer latency.
|
||||
old = messaging.new_message('aolIntentWire', 0, valid=True)
|
||||
old.logMonoTime = prior
|
||||
old.aolIntentWire = encode_intent(IntentState('card', 1, prior, prior, prior + 200_000_000,
|
||||
True, False, False, True, True))
|
||||
pm.send('aolIntentWire', old)
|
||||
sd.sm.update(100)
|
||||
assert sd.sm.seen['aolIntentWire']
|
||||
# Native permission is current and unchanged throughout the race.
|
||||
cp = car.CarParams.new_message()
|
||||
cp.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=34)]
|
||||
native = messaging.new_message('aolSafetyWire', 0, valid=True)
|
||||
native.logMonoTime = stamp
|
||||
native.aolSafetyWire = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000,
|
||||
int(car.CarParams.SafetyModel.hondaBosch), 34, True, True, True, True, 'panda', 'drive'))
|
||||
pm.send('aolSafetyWire', native)
|
||||
sd.sm.update(100)
|
||||
assert sd.sm.seen['aolSafetyWire']
|
||||
decisions = []
|
||||
|
||||
def send(service, message):
|
||||
pm.send(service, message)
|
||||
if service == 'carState':
|
||||
# Force the consumer to run at the earliest possible publication
|
||||
# boundary, before card can execute its next line. Both receive paths
|
||||
# are real sockets and data_sample retains its nonblocking SM update.
|
||||
state = sd.data_sample()
|
||||
now = stamp + 2_800_000
|
||||
intent = current_intent(sd.sm, car_state_ns=sd.aol_car_state_log_ns, now_ns=now)
|
||||
ack = current_native(sd.sm, cp, now_ns=now, axis_session_id='drive')
|
||||
assert ack is not None
|
||||
if qualified:
|
||||
assert intent is not None
|
||||
assert intent.carStateLogMonoTime == sd.aol_car_state_log_ns == stamp
|
||||
else:
|
||||
assert intent is None
|
||||
decisions.append(decide_axes(standard_lateral=True, standard_longitudinal=True,
|
||||
intent=intent, native=ack, car_state=state, initialized=True, model_ready=True,
|
||||
no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0))
|
||||
# The original 30 ms contract remains exact; no arrival-side renewal.
|
||||
assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 30_000_001) is None
|
||||
|
||||
card = Car.__new__(Car)
|
||||
card.__dict__.update(sm=SimpleNamespace(frame=1, all_checks=lambda _: True), pm=SimpleNamespace(send=send),
|
||||
CP=cp, car_params_published=True, slc_replay=False, curve_replay=False, conditional_replay=False,
|
||||
last_actuators_output=car.CarControl.Actuators.new_message(), can_rcv_cum_timeout_counter=0,
|
||||
rk=SimpleNamespace(remaining=0), aol_replay=True, aol_qualified=qualified, aol_sequence=1,
|
||||
slc_producer_session='card', aol_card_intent=SimpleNamespace(allowed_latch=True, output=lambda _: (True, pause_lateral, False)),
|
||||
v_cruise_helper=SimpleNamespace(slc_cruise_change=None))
|
||||
create = messaging.new_message
|
||||
|
||||
def stamped(service, *args, **kwargs):
|
||||
message = create(service, *args, **kwargs)
|
||||
if service == 'carState':
|
||||
message.logMonoTime = stamp
|
||||
return message
|
||||
|
||||
with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=stamped):
|
||||
card.state_publish(sd.CS_prev, None)
|
||||
assert len(decisions) == 1
|
||||
assert decisions[0].mode == expected
|
||||
|
||||
|
||||
def test_future_companion_only_reuses_same_car_state_original_lease():
|
||||
with OpenpilotPrefix():
|
||||
messaging.reset_context()
|
||||
pm = messaging.PubMaster(['carState', 'aolIntentWire'])
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.sm = messaging.SubMaster(['aolIntentWire'])
|
||||
sd.car_state_sock = messaging.sub_sock('carState', timeout=1)
|
||||
sd.initialized, sd.enabled = True, False
|
||||
sd.aol_car_state_log_ns, sd.conditional_car_state_valid = 0, False
|
||||
sd.CS_prev = car.CarState.new_message(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.)
|
||||
stamp = time.monotonic_ns()
|
||||
|
||||
def publish_intent(at, *, valid=True, qualified=True, session='card'):
|
||||
message = messaging.new_message('aolIntentWire', 0, valid=valid)
|
||||
message.logMonoTime = at
|
||||
message.aolIntentWire = encode_intent(IntentState(session, 1, at, at, at + 200_000_000,
|
||||
True, True, False, qualified, True))
|
||||
pm.send('aolIntentWire', message)
|
||||
|
||||
publish_intent(stamp)
|
||||
state = messaging.new_message('carState', valid=True)
|
||||
state.logMonoTime, state.carState = stamp, sd.CS_prev
|
||||
pm.send('carState', state)
|
||||
sd.CS_prev = sd.data_sample()
|
||||
previous = current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 1_000_000)
|
||||
assert previous is not None
|
||||
# Card has sent the next intent, but not its carState. Exercise the real
|
||||
# carState socket timeout and the real nonblocking SubMaster receive.
|
||||
publish_intent(stamp + 20_000_000)
|
||||
with patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=stamp + 23_000_000):
|
||||
assert sd.data_sample() is sd.CS_prev
|
||||
assert sd.aol_car_state_log_ns == stamp
|
||||
assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000) is None
|
||||
assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000, previous=previous) == previous
|
||||
assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 30_000_001, previous=previous) is None
|
||||
assert current_intent(sd.sm, car_state_ns=stamp - 1, now_ns=stamp + 23_000_000, previous=previous) is None
|
||||
for changes in ({'valid': False}, {'qualified': False}, {'session': 'new-card'}):
|
||||
publish_intent(stamp + 20_000_000, **changes)
|
||||
sd.sm.update(100)
|
||||
assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000, previous=previous) is None
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Exact stock Ioniq AOL admission and saved-request cleanup regression."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car.hyundai.values import CAR, HyundaiFlags
|
||||
from openpilot.starpilot.car.hyundai.aol import (policy_for, qualified_ioniq6, ioniq6_settings_capable,
|
||||
native_profile_supported, native_accepts_cp)
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings
|
||||
|
||||
|
||||
def stock(alternate):
|
||||
cp = car.CarParams.new_message()
|
||||
cp.brand = 'hyundai'
|
||||
cp.carFingerprint = CAR.HYUNDAI_IONIQ_6
|
||||
cp.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEER_MSG)
|
||||
if alternate:
|
||||
cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT)
|
||||
cp.pcmCruise = True
|
||||
cp.openpilotLongitudinalControl = False
|
||||
cp.radarUnavailable = False
|
||||
cp.safetyConfigs = [{'safetyModel': car.CarParams.SafetyModel.hyundaiCanfd,
|
||||
'safetyParam': 0x91 if alternate else 0x11}]
|
||||
return cp
|
||||
|
||||
|
||||
@pytest.mark.parametrize('alternate', [False, True])
|
||||
def test_stock_marker_is_lateral_only_and_native_exact(alternate):
|
||||
cp = stock(alternate)
|
||||
original = int(cp.safetyConfigs[0].safetyParam)
|
||||
policy = policy_for(cp)
|
||||
assert policy.intent_supported and policy.runtime_supported and policy.normal_runtime_supported
|
||||
assert policy.explicit_latch and policy.safety_param_addition == 0x0800
|
||||
assert not policy.distance_personality
|
||||
assert ioniq6_settings_capable(cp) and not qualified_ioniq6(cp)
|
||||
assert not native_accepts_cp(cp, int(car.CarParams.SafetyModel.hyundaiCanfd), original)
|
||||
intent = AolCardIntent(AolSettings(True, 0., 0, 0, (0,0,0), (0,0,0)), explicit_latch=policy.explicit_latch)
|
||||
assert intent.explicit_latch and not intent.allowed_latch
|
||||
cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition
|
||||
assert qualified_ioniq6(cp) and ioniq6_settings_capable(cp)
|
||||
assert native_profile_supported(int(car.CarParams.SafetyModel.hyundaiCanfd), original | 0x800)
|
||||
assert native_accepts_cp(cp, int(car.CarParams.SafetyModel.hyundaiCanfd), original | 0x800)
|
||||
assert cp.pcmCruise and not cp.openpilotLongitudinalControl and not cp.alphaLongitudinalAvailable
|
||||
cp.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS)
|
||||
assert not policy_for(cp).runtime_supported and not qualified_ioniq6(cp)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('alternate', [False, True])
|
||||
def test_existing_long_profile_retained(alternate):
|
||||
cp = stock(alternate)
|
||||
cp.pcmCruise = False
|
||||
cp.openpilotLongitudinalControl = True
|
||||
cp.safetyConfigs[0].safetyParam = 0x8895 if alternate else 0x8815
|
||||
assert qualified_ioniq6(cp)
|
||||
assert policy_for(cp).intent_supported and policy_for(cp).explicit_latch
|
||||
|
||||
|
||||
def cleanup(cp):
|
||||
# Execute the actual saved-request cleanup nodes without initializing processes.
|
||||
source = Path(__file__).parents[3] / 'selfdrive/selfdrived/selfdrived.py'
|
||||
tree = ast.parse(source.read_text())
|
||||
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'SelfdriveD')
|
||||
method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == '__init__')
|
||||
# Execute the real startup cleanup condition; constructor side effects stay absent.
|
||||
nodes: list[ast.stmt] = [node for node in method.body if isinstance(node, ast.If) and any(
|
||||
isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr == 'remove'
|
||||
and call.args and isinstance(call.args[0], ast.Constant)
|
||||
and call.args[0].value in ('AlphaLongitudinalEnabled', 'ExperimentalMode') for call in ast.walk(node))]
|
||||
calls = []
|
||||
host = SimpleNamespace(CP=cp, params=SimpleNamespace(remove=calls.append))
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), {'self': host})
|
||||
return calls
|
||||
|
||||
|
||||
def test_actual_cleanup_preserves_request_without_enabling_long():
|
||||
cp = stock(True)
|
||||
assert cleanup(cp) == ['ExperimentalMode']
|
||||
cp.passive = True
|
||||
cp.dashcamOnly = True
|
||||
assert not qualified_ioniq6(cp) and not ioniq6_settings_capable(cp)
|
||||
assert cleanup(cp) == ['ExperimentalMode']
|
||||
cp.carFingerprint = CAR.HYUNDAI_IONIQ_5
|
||||
assert cleanup(cp) == ['ExperimentalMode']
|
||||
cp.openpilotLongitudinalControl = True
|
||||
assert cleanup(cp) == []
|
||||
@@ -0,0 +1,706 @@
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import AlertManager
|
||||
from openpilot.selfdrive.selfdrived.events import Events, ET
|
||||
from openpilot.selfdrive.selfdrived.state import StateMachine
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings, disarming_fault, read_settings
|
||||
from openpilot.starpilot.aol.runtime import AxisDecision, current_intent, current_native, decide_axes
|
||||
from openpilot.starpilot.aol.wire import IntentState, SafetyState, encode_intent, encode_safety
|
||||
|
||||
|
||||
def car_state(*, brake=False):
|
||||
return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive,
|
||||
vEgo=20.0, brakePressed=brake)
|
||||
|
||||
|
||||
class CardIntentTests(unittest.TestCase):
|
||||
def test_temporary_eps_and_known_gears_preserve_latch_with_zero_steering(self):
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
state = car_state()
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=False)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for gear, temporary, name in (
|
||||
(car.CarState.GearShifter.drive, True, log.OnroadEvent.EventName.steerTempUnavailable),
|
||||
(car.CarState.GearShifter.neutral, False, log.OnroadEvent.EventName.wrongGear),
|
||||
(car.CarState.GearShifter.reverse, False, log.OnroadEvent.EventName.wrongGear),
|
||||
(car.CarState.GearShifter.park, False, log.OnroadEvent.EventName.wrongGear)):
|
||||
with self.subTest(gear=gear, temporary=temporary):
|
||||
state.gearShifter = gear
|
||||
state.steerFaultTemporary = temporary
|
||||
events = Events()
|
||||
events.add(name)
|
||||
fault = disarming_fault(events.to_msg(), state)
|
||||
self.assertFalse(fault)
|
||||
for _ in range(100):
|
||||
owner.update(state, fault_active=fault)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
allowed, pause_lat, pause_long = owner.output(state)
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=False,
|
||||
intent=SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long),
|
||||
native=SimpleNamespace(requestedLateral=False, requestedLongitudinal=False, lateralAllowed=False, longitudinalAllowed=False),
|
||||
car_state=state, initialized=True, model_ready=True, no_entry=True, immediate_disable=False,
|
||||
dm_lockout=False, pause_brake_mps=0)
|
||||
self.assertFalse(decision.desired_lateral)
|
||||
self.assertFalse(decision.lateral_active)
|
||||
state.gearShifter = car.CarState.GearShifter.drive
|
||||
state.steerFaultTemporary = False
|
||||
owner.update(state, fault_active=False)
|
||||
self.assertTrue(owner.output(state)[0])
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=False,
|
||||
intent=SimpleNamespace(allowedLatch=owner.output(state)[0], pauseLateral=False, pauseLongitudinal=False),
|
||||
native=SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=True, longitudinalAllowed=False),
|
||||
car_state=state, initialized=True, model_ready=True, no_entry=False, immediate_disable=False,
|
||||
dm_lockout=False, pause_brake_mps=0)
|
||||
self.assertTrue(decision.lateral_active)
|
||||
|
||||
def test_low_speed_brake_suppresses_output_without_destroying_explicit_latch(self):
|
||||
owner = AolCardIntent(AolSettings(True, 5.0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
state = car_state()
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
events = Events()
|
||||
events.add(log.OnroadEvent.EventName.pedalPressed)
|
||||
for speed, brake, expected in ((2., True, False), (2., False, True), (6., True, True)):
|
||||
state.vEgo, state.brakePressed = speed, brake
|
||||
owner.update(state, fault_active=disarming_fault(events.to_msg(), state))
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
allowed, pause_lat, pause_long = owner.output(state)
|
||||
axes = decide_axes(standard_lateral=False, standard_longitudinal=False,
|
||||
intent=SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long),
|
||||
native=SimpleNamespace(requestedLateral=expected, requestedLongitudinal=False,
|
||||
lateralAllowed=expected, longitudinalAllowed=False),
|
||||
car_state=state, initialized=True, model_ready=True, no_entry=False, immediate_disable=False,
|
||||
dm_lockout=False, pause_brake_mps=5.0)
|
||||
self.assertEqual(axes.lateral_active, expected)
|
||||
self.assertFalse(axes.longitudinal_active)
|
||||
|
||||
def test_permanent_and_unrelated_faults_still_disarm(self):
|
||||
state = car_state()
|
||||
for name in (log.OnroadEvent.EventName.steerUnavailable, log.OnroadEvent.EventName.controlsMismatch,
|
||||
log.OnroadEvent.EventName.overheat):
|
||||
events = Events()
|
||||
events.add(name)
|
||||
self.assertTrue(disarming_fault(events.to_msg(), state))
|
||||
events = Events()
|
||||
events.add(log.OnroadEvent.EventName.wrongGear)
|
||||
self.assertFalse(disarming_fault(events.to_msg(), state)) # Events may lag the return to Drive.
|
||||
state.gearShifter = car.CarState.GearShifter.unknown
|
||||
self.assertTrue(disarming_fault(events.to_msg(), state))
|
||||
state.steerFaultPermanent = True
|
||||
self.assertTrue(disarming_fault([], state))
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
owner.allowed_latch = True
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
|
||||
def test_native_rejection_rearms_with_one_new_press_without_replaying_old_receipt(self):
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
state = car_state()
|
||||
lkas = car.CarState.ButtonEvent.Type.lkas
|
||||
owner.update(state, now_ns=100)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state, now_ns=200)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(state, now_ns=300)
|
||||
state.buttonEvents = []
|
||||
owner.update(state, now_ns=400, native_rejection_ns=350)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state, now_ns=500, native_rejection_ns=350)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
state.buttonEvents = []
|
||||
owner.update(state, now_ns=600, native_rejection_ns=450)
|
||||
self.assertTrue(owner.allowed_latch) # Delayed denial predates the new press.
|
||||
owner.update(state, now_ns=700, native_rejection_ns=650)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
owner.update(state, now_ns=800)
|
||||
self.assertFalse(owner.allowed_latch) # The held physical button cannot rearm.
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(state, now_ns=900)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state, now_ns=1000)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
|
||||
def test_native_rejection_and_fresh_press_same_frame_preserve_fault_inhibit(self):
|
||||
for fault in (False, True):
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
state = car_state()
|
||||
owner.update(state, now_ns=100)
|
||||
owner.allowed_latch = True
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
|
||||
owner.update(state, now_ns=200, native_rejection_ns=150, fault_active=fault)
|
||||
self.assertEqual(owner.allowed_latch, not fault)
|
||||
|
||||
def test_explicit_latch_requires_new_gesture_after_fault(self):
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True)
|
||||
state = car_state()
|
||||
lkas = car.CarState.ButtonEvent.Type.lkas
|
||||
owner.update(state, fault_active=False)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
|
||||
state.buttonEvents = []
|
||||
owner.update(state, fault_active=True)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(state, fault_active=True)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state, fault_active=True)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state) # No fresh clear event yet.
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
|
||||
# A held button at recovery is not a new request. The driver must release it first.
|
||||
owner.update(state, fault_active=False)
|
||||
self.assertFalse(owner.allowed_latch)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)]
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.allowed_latch)
|
||||
|
||||
def test_persisted_preferences_and_fresh_defaults(self):
|
||||
with OpenpilotPrefix():
|
||||
params = Params()
|
||||
fresh = read_settings(params)
|
||||
self.assertFalse(fresh.enabled)
|
||||
self.assertEqual(fresh.pause_brake_mps, 0.0)
|
||||
self.assertEqual(fresh.distance_actions, (0, 0, 0))
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
params.put('PauseAOLOnBrake', 10.0, block=True)
|
||||
params.put('DistanceButtonControl', 3, block=True)
|
||||
saved = read_settings(params)
|
||||
self.assertTrue(saved.enabled)
|
||||
self.assertEqual(saved.pause_brake_mps, 10.0)
|
||||
self.assertEqual(saved.distance_actions, (3, 0, 0))
|
||||
|
||||
def test_brake_pause_units_preserve_legacy_until_explicit_si_override(self):
|
||||
with OpenpilotPrefix():
|
||||
params = Params()
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
params.put('PauseAOLOnBrake', 10.0, block=True)
|
||||
for metric in (False, True):
|
||||
params.put_bool('IsMetric', metric, block=True)
|
||||
self.assertEqual(read_settings(params).pause_brake_mps, 10.0)
|
||||
params.put('AolBrakePauseSpeedMps', 4.4704, block=True)
|
||||
self.assertAlmostEqual(read_settings(params).pause_brake_mps, 4.4704)
|
||||
self.assertTrue(read_settings(params).enabled)
|
||||
params.put('AolBrakePauseSpeedMps', 0.0, block=True)
|
||||
self.assertEqual(read_settings(params).pause_brake_mps, 0.0)
|
||||
params.put('AolBrakePauseSpeedMps', -1.0, block=True)
|
||||
self.assertFalse(read_settings(params).enabled)
|
||||
self.assertTrue(params.get_bool('AlwaysOnLateral'))
|
||||
params.remove('AolBrakePauseSpeedMps')
|
||||
self.assertEqual(read_settings(params).pause_brake_mps, 10.0)
|
||||
|
||||
def test_invalid_threshold_never_falls_back_to_permissive_value(self):
|
||||
with OpenpilotPrefix():
|
||||
params = Params()
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
params.put('PauseAOLOnBrake', 10.0, block=True)
|
||||
for key in ('AolBrakePauseSpeedMps', 'PauseAOLOnBrake'):
|
||||
path = Path(params.get_param_path(key))
|
||||
for value in (b'', b'bad', b'\xff', b'nan', b'inf', b'-1', b'100.1'):
|
||||
with self.subTest(key=key, value=value):
|
||||
path.write_bytes(value)
|
||||
self.assertFalse(read_settings(params).enabled)
|
||||
self.assertTrue(params.get_bool('AlwaysOnLateral'))
|
||||
path.unlink()
|
||||
self.assertTrue(read_settings(params).enabled)
|
||||
self.assertEqual(read_settings(params).pause_brake_mps, 0.0)
|
||||
|
||||
def test_saved_threshold_controls_actual_brake_boundary(self):
|
||||
with OpenpilotPrefix():
|
||||
params = Params()
|
||||
params.put_bool('AlwaysOnLateral', True, block=True)
|
||||
params.put('PauseAOLOnBrake', 10.0, block=True)
|
||||
state = car_state(brake=True)
|
||||
native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False,
|
||||
lateralAllowed=True, longitudinalAllowed=False)
|
||||
intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False)
|
||||
for metric in (False, True):
|
||||
params.put_bool('IsMetric', metric, block=True)
|
||||
for speed, mode in ((9.9, 'off'), (10.0, 'lateralOnly')):
|
||||
state.vEgo = speed
|
||||
result = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent,
|
||||
native=native, car_state=state, initialized=True, model_ready=True,
|
||||
no_entry=False, immediate_disable=False, dm_lockout=False,
|
||||
pause_brake_mps=read_settings(params).pause_brake_mps)
|
||||
self.assertEqual(result.mode, mode)
|
||||
|
||||
def test_honda_main_lkas_pause_and_cancel_ownership(self):
|
||||
settings = AolSettings(True, 5.0, 9, 0, (3, 4, 0), (3, 0, 0))
|
||||
owner = AolCardIntent(settings)
|
||||
state = car_state()
|
||||
state.cruiseState.available = True
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.output(state)[0]) # LKAS button owns the latch
|
||||
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.output(state)[0])
|
||||
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)]
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_lateral) # Honda cancel never doubles as pause
|
||||
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.gapAdjustCruise, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.gapAdjustCruise, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
self.assertEqual(owner.output(state), (True, True, False))
|
||||
|
||||
def test_invalid_can_and_settings_disable_intent(self):
|
||||
owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)))
|
||||
state = car_state()
|
||||
state.cruiseState.available = True
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.output(state)[0])
|
||||
state.canValid = False
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.output(state)[0])
|
||||
|
||||
def test_interrupted_distance_hold_does_not_replay_and_keeps_deliberate_pause(self):
|
||||
settings = AolSettings(True, 0, 0, 0, (3, 4, 0), (0, 0, 0))
|
||||
owner = AolCardIntent(settings)
|
||||
state = car_state()
|
||||
state.cruiseState.available = True
|
||||
button = car.CarState.ButtonEvent.Type.gapAdjustCruise
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for _ in range(5):
|
||||
owner.update(state)
|
||||
state.canValid = False
|
||||
owner.update(state)
|
||||
self.assertFalse(owner._held[int(button)])
|
||||
self.assertEqual(owner._timers[int(button)], 0)
|
||||
state.canValid = True
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_lateral or owner.pause_longitudinal)
|
||||
|
||||
# A complete user pause remains latched through CAN loss.
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
state.canValid = False
|
||||
owner.update(state)
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
|
||||
# If another owner consumed a release, it cannot complete the held gesture.
|
||||
state.canValid = True
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state, consumed_buttons=frozenset((int(button),)))
|
||||
self.assertFalse(owner._held[int(button)])
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
|
||||
# CAN loss just before the long-press boundary cannot finish that gesture.
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for _ in range(CRUISE_LONG_PRESS - 2):
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_longitudinal)
|
||||
state.canValid = False
|
||||
owner.update(state)
|
||||
state.canValid = True
|
||||
for _ in range(3):
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_longitudinal)
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
|
||||
# An owner can reserve the button on a tick with no new button event.
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for _ in range(CRUISE_LONG_PRESS - 2):
|
||||
owner.update(state)
|
||||
owner.update(state, consumed_buttons=frozenset((int(button),)))
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_longitudinal)
|
||||
self.assertTrue(owner.pause_lateral)
|
||||
|
||||
def test_held_at_start_distance_waits_for_neutral_before_all_press_lengths(self):
|
||||
button = car.CarState.ButtonEvent.Type.gapAdjustCruise
|
||||
for held_ticks, actions in ((1, (3, 0, 0)), (CRUISE_LONG_PRESS, (0, 4, 0)),
|
||||
(CRUISE_LONG_PRESS * 5, (0, 0, 3))):
|
||||
with self.subTest(held_ticks=held_ticks):
|
||||
settings = AolSettings(True, 0, 0, 0, actions, (0, 0, 0))
|
||||
owner = AolCardIntent(settings, explicit_latch=True)
|
||||
state = car_state()
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for _ in range(held_ticks):
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertFalse(owner.pause_lateral or owner.pause_longitudinal)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
owner.update(state)
|
||||
state.buttonEvents = []
|
||||
for _ in range(held_ticks - 1):
|
||||
owner.update(state)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
owner.update(state)
|
||||
self.assertEqual((owner.pause_lateral, owner.pause_longitudinal),
|
||||
(held_ticks != CRUISE_LONG_PRESS, held_ticks == CRUISE_LONG_PRESS))
|
||||
|
||||
|
||||
|
||||
|
||||
class IpcAxisContractTests(unittest.TestCase):
|
||||
def test_car_state_timeout_retains_only_original_fresh_valid_sample(self):
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.car_state_sock = object()
|
||||
sd.CS_prev = car_state()
|
||||
sd.aol_car_state_log_ns = 0
|
||||
sd.conditional_car_state_valid = False
|
||||
sd.initialized = True
|
||||
sd.enabled = False
|
||||
self.enterContext(mock.patch.object(sd, 'sm', SimpleNamespace(update=mock.Mock()), create=True))
|
||||
stamp = 1_000_000_000
|
||||
message = messaging.new_message('carState')
|
||||
message.logMonoTime = stamp
|
||||
message.valid = True
|
||||
message.carState = sd.CS_prev
|
||||
with (mock.patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one', return_value=message) as recv,
|
||||
mock.patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=stamp) as now):
|
||||
sd.CS_prev = sd.data_sample()
|
||||
self.assertEqual(sd.aol_car_state_log_ns, stamp)
|
||||
recv.return_value = None # 20 ms socket timeout, still inside the existing 30 ms lease.
|
||||
now.return_value = stamp + 23_000_000
|
||||
self.assertIs(sd.data_sample(), sd.CS_prev)
|
||||
self.assertEqual(sd.aol_car_state_log_ns, stamp)
|
||||
self.assertTrue(sd.conditional_car_state_valid)
|
||||
now.return_value = stamp + 30_000_001
|
||||
sd.data_sample()
|
||||
self.assertEqual(sd.aol_car_state_log_ns, 0)
|
||||
self.assertFalse(sd.conditional_car_state_valid)
|
||||
|
||||
# A next-drive/new process has no retained source, even if CS_prev exists.
|
||||
sd.data_sample()
|
||||
self.assertEqual(sd.aol_car_state_log_ns, 0)
|
||||
message.logMonoTime = stamp + 24_000_000
|
||||
recv.return_value = message
|
||||
sd.CS_prev = sd.data_sample()
|
||||
self.assertEqual(sd.aol_car_state_log_ns, stamp + 24_000_000)
|
||||
for invalid_kind in ('valid', 'canValid', 'canTimeout', 'clock'):
|
||||
with self.subTest(invalid_kind=invalid_kind):
|
||||
message.valid = invalid_kind != 'valid'
|
||||
message.carState.canValid = invalid_kind != 'canValid'
|
||||
message.carState.canTimeout = invalid_kind == 'canTimeout'
|
||||
recv.return_value = message
|
||||
sd.CS_prev = sd.data_sample()
|
||||
recv.return_value = None
|
||||
now.return_value = message.logMonoTime + (-1 if invalid_kind == 'clock' else 20_000_000)
|
||||
sd.data_sample()
|
||||
self.assertEqual(sd.aol_car_state_log_ns, 0)
|
||||
self.assertFalse(sd.conditional_car_state_valid)
|
||||
|
||||
def test_lateral_only_receipt_loss_keeps_take_control_alert(self):
|
||||
class SM:
|
||||
frame = 0
|
||||
|
||||
def __getitem__(self, service):
|
||||
if service == 'driverMonitoringState':
|
||||
return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False)
|
||||
if service == 'extrinsicsCalibration':
|
||||
return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated)
|
||||
raise KeyError(service)
|
||||
|
||||
def all_checks(self, _services):
|
||||
return True
|
||||
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True)
|
||||
self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True))
|
||||
sd.events = Events()
|
||||
sd.state_machine = StateMachine()
|
||||
sd.AM = AlertManager()
|
||||
sd.personality = 1
|
||||
sd.is_metric = False
|
||||
sd.enabled = sd.active = False
|
||||
sd.initialized = True
|
||||
sd.aol_replay = True
|
||||
sd.aol_car_state_log_ns = 0
|
||||
sd.aol_session_id = 'drive-session'
|
||||
sd.aol_axis_decision = AxisDecision()
|
||||
sd.aol_dm_lateral_inhibit = False
|
||||
sd.aol_settings = None
|
||||
sd.nostalgia_paddle_cancel = False
|
||||
self.enterContext(mock.patch.object(sd, 'data_sample', car_state))
|
||||
self.enterContext(mock.patch.object(sd, 'update_events', lambda _cs: sd.events.clear()))
|
||||
sd.update_conditional_mode = mock.Mock()
|
||||
sd.publish_selfdriveState = mock.Mock()
|
||||
|
||||
native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False,
|
||||
lateralAllowed=True, longitudinalAllowed=False)
|
||||
intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False)
|
||||
with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent), \
|
||||
mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', side_effect=(None, native, None, None)):
|
||||
sd.sm.frame = 1
|
||||
sd.step()
|
||||
self.assertEqual(sd.aol_axis_decision.mode, 'off')
|
||||
self.assertFalse(sd.enabled)
|
||||
self.assertNotIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types)
|
||||
self.assertNotEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY')
|
||||
|
||||
sd.sm.frame = 2
|
||||
sd.step()
|
||||
self.assertEqual(sd.aol_axis_decision.mode, 'lateralOnly')
|
||||
self.assertFalse(sd.enabled)
|
||||
|
||||
sd.sm.frame = 3
|
||||
sd.step()
|
||||
self.assertEqual(sd.aol_axis_decision.mode, 'off')
|
||||
self.assertFalse(sd.enabled)
|
||||
self.assertIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types)
|
||||
self.assertEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY')
|
||||
self.assertEqual(sd.AM.current_alert.alert_text_2, 'Controls Mismatch')
|
||||
alert_end_frame = sd.AM.alerts['controlsMismatch/immediateDisable'].end_frame
|
||||
self.assertGreater(alert_end_frame, sd.sm.frame)
|
||||
|
||||
sd.sm.frame = 4
|
||||
sd.step()
|
||||
self.assertNotIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types)
|
||||
self.assertEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY')
|
||||
self.assertEqual(sd.AM.alerts['controlsMismatch/immediateDisable'].end_frame, alert_end_frame)
|
||||
|
||||
def test_lateral_only_temporary_fault_uses_existing_orange_warning(self):
|
||||
class SM:
|
||||
frame = 1
|
||||
|
||||
def __getitem__(self, service):
|
||||
if service == 'driverMonitoringState':
|
||||
return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False)
|
||||
if service == 'extrinsicsCalibration':
|
||||
return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated)
|
||||
raise KeyError(service)
|
||||
|
||||
def all_checks(self, _services):
|
||||
return True
|
||||
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True)
|
||||
self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True))
|
||||
sd.events, sd.state_machine, sd.AM = Events(), StateMachine(), AlertManager()
|
||||
sd.personality, sd.is_metric = 1, False
|
||||
sd.enabled = sd.active = False
|
||||
sd.initialized = sd.aol_replay = True
|
||||
sd.aol_car_state_log_ns = 0
|
||||
sd.aol_session_id = 'drive-session'
|
||||
sd.aol_axis_decision = AxisDecision()
|
||||
sd.aol_dm_lateral_inhibit = False
|
||||
sd.aol_settings = None
|
||||
sd.nostalgia_paddle_cancel = False
|
||||
state = car_state()
|
||||
state.steerFaultTemporary = True
|
||||
self.enterContext(mock.patch.object(sd, 'data_sample', return_value=state))
|
||||
self.enterContext(mock.patch.object(sd, 'update_events', lambda _cs: sd.events.clear()))
|
||||
sd.update_conditional_mode = mock.Mock()
|
||||
sd.publish_selfdriveState = mock.Mock()
|
||||
native = SimpleNamespace(requestedLateral=False, requestedLongitudinal=False,
|
||||
lateralAllowed=False, longitudinalAllowed=False)
|
||||
intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False)
|
||||
with (mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent),
|
||||
mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', return_value=native)):
|
||||
sd.step()
|
||||
self.assertFalse(sd.enabled)
|
||||
self.assertFalse(sd.aol_axis_decision.desired_lateral)
|
||||
self.assertFalse(sd.aol_axis_decision.lateral_active)
|
||||
self.assertEqual(sd.AM.current_alert.alert_type, 'steerTempUnavailableSilent/warning')
|
||||
self.assertEqual(sd.AM.current_alert.alert_status, log.SelfdriveState.AlertStatus.userPrompt)
|
||||
self.assertEqual(sd.AM.current_alert.alert_text_1, 'Steering Assist Temporarily Unavailable')
|
||||
state.steerFaultTemporary = False
|
||||
native.requestedLateral = native.lateralAllowed = True
|
||||
sd.sm.frame += 1
|
||||
sd.step()
|
||||
self.assertTrue(sd.aol_axis_decision.lateral_active)
|
||||
self.assertNotIn(log.OnroadEvent.EventName.steerTempUnavailableSilent, sd.events.names)
|
||||
|
||||
def test_native_receipt_gates_engagement_and_disables_on_loss(self):
|
||||
class SM:
|
||||
def __getitem__(self, service):
|
||||
if service == 'driverMonitoringState':
|
||||
return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False)
|
||||
if service == 'extrinsicsCalibration':
|
||||
return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated)
|
||||
raise KeyError(service)
|
||||
|
||||
def all_checks(self, _services):
|
||||
return True
|
||||
|
||||
def driver(aol_enabled):
|
||||
sd = SelfdriveD.__new__(SelfdriveD)
|
||||
sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True)
|
||||
self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True))
|
||||
sd.events = Events()
|
||||
sd.state_machine = StateMachine()
|
||||
sd.enabled = sd.active = False
|
||||
sd.initialized = True
|
||||
sd.aol_replay = aol_enabled
|
||||
sd.aol_car_state_log_ns = 0
|
||||
sd.aol_session_id = 'drive-session'
|
||||
sd.aol_axis_decision = AxisDecision()
|
||||
sd.aol_dm_lateral_inhibit = False
|
||||
sd.aol_settings = None
|
||||
sd.nostalgia_paddle_cancel = False
|
||||
self.enterContext(mock.patch.object(sd, 'data_sample', car_state))
|
||||
sd.update_alerts = mock.Mock()
|
||||
sd.update_conditional_mode = mock.Mock()
|
||||
sd.publish_selfdriveState = mock.Mock()
|
||||
self.enterContext(mock.patch.object(sd, 'update_events',
|
||||
lambda _cs: (sd.events.clear(), sd.events.add(log.OnroadEvent.EventName.buttonEnable))))
|
||||
return sd
|
||||
|
||||
native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=True,
|
||||
lateralAllowed=True, longitudinalAllowed=True)
|
||||
intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False)
|
||||
sd = driver(True)
|
||||
with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent), \
|
||||
mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', side_effect=(None, native, None)):
|
||||
sd.step()
|
||||
self.assertFalse(sd.enabled)
|
||||
self.assertTrue(sd.events.contains(ET.NO_ENTRY))
|
||||
self.assertEqual(sd.aol_axis_decision.mode, 'off')
|
||||
sd.publish_selfdriveState.assert_called_once() # disabled bootstrap still publishes the zero-axis request
|
||||
sd.step()
|
||||
self.assertTrue(sd.enabled and sd.active)
|
||||
self.assertEqual(sd.aol_axis_decision.mode, 'combined')
|
||||
sd.step()
|
||||
self.assertFalse(sd.enabled or sd.active)
|
||||
self.assertTrue(sd.events.contains(ET.IMMEDIATE_DISABLE))
|
||||
alerts = sd.events.create_alerts(sd.state_machine.current_alert_types)
|
||||
self.assertTrue(any(alert.alert_text_1 == 'TAKE CONTROL IMMEDIATELY' and
|
||||
alert.alert_text_2 == 'Controls Mismatch' for alert in alerts))
|
||||
|
||||
stock = driver(False)
|
||||
with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native') as read_native:
|
||||
stock.step()
|
||||
read_native.assert_not_called()
|
||||
self.assertTrue(stock.enabled and stock.active)
|
||||
|
||||
def test_independent_ack_keeps_unchanged_axis(self):
|
||||
state = car_state()
|
||||
old_combined = SimpleNamespace(requestedLateral=True, requestedLongitudinal=True,
|
||||
lateralAllowed=True, longitudinalAllowed=True)
|
||||
intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False)
|
||||
def decide(standard_long, native):
|
||||
return decide_axes(standard_lateral=False, standard_longitudinal=standard_long,
|
||||
intent=intent, native=native, car_state=state, initialized=True,
|
||||
model_ready=True, no_entry=False, immediate_disable=False,
|
||||
dm_lockout=False, pause_brake_mps=5.0)
|
||||
self.assertEqual(decide(True, old_combined).mode, 'combined')
|
||||
self.assertEqual(decide(False, old_combined).mode, 'lateralOnly')
|
||||
old_lateral = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False,
|
||||
lateralAllowed=True, longitudinalAllowed=False)
|
||||
self.assertEqual(decide(True, old_lateral).mode, 'lateralOnly')
|
||||
intent.pauseLateral = True
|
||||
self.assertEqual(decide(True, old_combined).mode, 'longitudinalOnly')
|
||||
intent.pauseLateral = False
|
||||
intent.pauseLongitudinal = True
|
||||
self.assertEqual(decide(True, old_combined).mode, 'lateralOnly')
|
||||
intent.pauseLongitudinal = False
|
||||
old_long = SimpleNamespace(requestedLateral=False, requestedLongitudinal=True,
|
||||
lateralAllowed=False, longitudinalAllowed=True)
|
||||
self.assertEqual(decide(True, old_long).mode, 'longitudinalOnly')
|
||||
|
||||
|
||||
def test_card_selfdrive_panda_cadence_and_four_modes(self):
|
||||
with OpenpilotPrefix():
|
||||
messaging.reset_context()
|
||||
pm = messaging.PubMaster(['carState', 'aolIntentWire', 'aolAxisState', 'aolSafetyWire'])
|
||||
sm = messaging.SubMaster(['carState', 'aolIntentWire', 'aolAxisState', 'aolSafetyWire'])
|
||||
base = time.monotonic_ns() - 50_000_000
|
||||
CP = car.CarParams()
|
||||
CP.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=34)]
|
||||
|
||||
def publish_intent(stamp, *, pause_lat=False, pause_long=False):
|
||||
message = messaging.new_message('aolIntentWire', 0)
|
||||
message.logMonoTime = stamp
|
||||
message.valid = True
|
||||
message.aolIntentWire = encode_intent(IntentState(
|
||||
'card-session', stamp - base + 1, stamp, stamp, stamp + 100_000_000,
|
||||
True, pause_lat, pause_long, True))
|
||||
pm.send('aolIntentWire', message)
|
||||
|
||||
def publish_native(stamp, lat, long, session='drive-session'):
|
||||
message = messaging.new_message('aolSafetyWire', 0)
|
||||
message.logMonoTime = stamp
|
||||
message.valid = True
|
||||
message.aolSafetyWire = encode_safety(SafetyState(
|
||||
1, True, stamp, stamp + 200_000_000, int(car.CarParams.SafetyModel.hondaBosch),
|
||||
34, lat, long, lat, long, 'panda', session))
|
||||
pm.send('aolSafetyWire', message)
|
||||
|
||||
for index, (standard_long, pause_lat, pause_long, expected) in enumerate((
|
||||
(False, False, False, 'lateralOnly'),
|
||||
(True, False, False, 'combined'),
|
||||
(True, True, False, 'longitudinalOnly'),
|
||||
(False, True, False, 'off'),
|
||||
)):
|
||||
stamp = base + index * 10_000_000
|
||||
state_msg = messaging.new_message('carState')
|
||||
state_msg.logMonoTime = stamp
|
||||
state_msg.valid = True
|
||||
pm.send('carState', state_msg)
|
||||
publish_intent(stamp, pause_lat=pause_lat, pause_long=pause_long)
|
||||
desired_lat = not pause_lat
|
||||
desired_long = standard_long and not pause_long
|
||||
publish_native(stamp, desired_lat, desired_long)
|
||||
sm.update(100)
|
||||
intent = current_intent(sm, car_state_ns=stamp, now_ns=stamp + 1_000_000)
|
||||
native = current_native(sm, CP, now_ns=stamp + 1_000_000, axis_session_id='drive-session')
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=standard_long,
|
||||
intent=intent, native=native, car_state=car_state(),
|
||||
initialized=True, model_ready=True, no_entry=False,
|
||||
immediate_disable=False, dm_lockout=False, pause_brake_mps=5.0)
|
||||
self.assertEqual(decision.mode, expected)
|
||||
|
||||
self.assertIsNone(current_native(sm, CP, now_ns=base + 500_000_000, axis_session_id='drive-session'))
|
||||
self.assertIsNone(current_native(sm, CP, now_ns=base + 31_000_000, axis_session_id='new-session'))
|
||||
|
||||
def test_no_native_ack_still_requests_but_does_not_actuate(self):
|
||||
intent = SimpleNamespace(pauseLateral=False, pauseLongitudinal=False, allowedLatch=True)
|
||||
decision = decide_axes(standard_lateral=False, standard_longitudinal=True,
|
||||
intent=intent, native=None, car_state=car_state(), initialized=True,
|
||||
model_ready=True, no_entry=False, immediate_disable=False,
|
||||
dm_lockout=False, pause_brake_mps=5.0)
|
||||
self.assertTrue(decision.desired_lateral)
|
||||
self.assertTrue(decision.desired_longitudinal)
|
||||
self.assertEqual(decision.mode, 'off')
|
||||
self.assertFalse(decision.native_acknowledged)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Joined adapter-factory regression for stock CAN-FD and classic lateral policies."""
|
||||
import unittest
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car.hyundai.values import CAR, HyundaiFlags
|
||||
from openpilot.starpilot.aol.intent import AolSettings
|
||||
from openpilot.starpilot.aol.vehicle import create_intent, native_matches_cp, policy_for
|
||||
from openpilot.starpilot.car.hyundai.forte_intent import ForteCardIntent
|
||||
from openpilot.starpilot.aol.tests.test_forte_intent import params as forte_params, state as forte_state
|
||||
|
||||
Button = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def stock(alternate):
|
||||
cp = car.CarParams.new_message()
|
||||
cp.brand, cp.carFingerprint = 'hyundai', CAR.HYUNDAI_IONIQ_6
|
||||
cp.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEER_MSG)
|
||||
if alternate:
|
||||
cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT)
|
||||
cp.pcmCruise, cp.openpilotLongitudinalControl, cp.radarUnavailable = True, False, False
|
||||
cp.safetyConfigs = [{'safetyModel': car.CarParams.SafetyModel.hyundaiCanfd,
|
||||
'safetyParam': 0x91 if alternate else 0x11}]
|
||||
return cp
|
||||
|
||||
|
||||
def settings(lkas=9, main=0):
|
||||
return AolSettings(True, 0., lkas, main, (0, 0, 0), (0, 0, 0))
|
||||
|
||||
|
||||
class TestStockForteFactory(unittest.TestCase):
|
||||
def test_actual_dispatcher_stock_factory_retains_latch_before_marker(self):
|
||||
for alternate in (False, True):
|
||||
with self.subTest(alternate=alternate):
|
||||
cp = stock(alternate)
|
||||
policy = policy_for(cp)
|
||||
intent = create_intent(cp, settings(), policy)
|
||||
self.assertTrue(policy.intent_supported and policy.explicit_latch)
|
||||
self.assertTrue(intent.explicit_latch)
|
||||
self.assertFalse(intent.allowed_latch)
|
||||
model, original = int(cp.safetyConfigs[0].safetyModel.raw), int(cp.safetyConfigs[0].safetyParam)
|
||||
self.assertFalse(native_matches_cp(cp, model, original))
|
||||
cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition
|
||||
serialized = cp.as_reader().as_builder()
|
||||
self.assertEqual(serialized.safetyConfigs[0].safetyParam, original | 0x0800)
|
||||
self.assertTrue(native_matches_cp(serialized, model, original | 0x0800))
|
||||
self.assertFalse(native_matches_cp(serialized, model, original))
|
||||
self.assertTrue(serialized.pcmCruise)
|
||||
self.assertFalse(serialized.openpilotLongitudinalControl)
|
||||
disabled = create_intent(stock(alternate), AolSettings(False, 0., 9, 0, (0, 0, 0), (0, 0, 0)), policy)
|
||||
self.assertTrue(disabled.explicit_latch)
|
||||
self.assertFalse(disabled.allowed_latch)
|
||||
|
||||
def test_forte_dispatcher_retains_direct_button_state_machine(self):
|
||||
for platform in (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC):
|
||||
for source in (0, 0x391, 0x50c):
|
||||
for main_action in (0, 9):
|
||||
with self.subTest(platform=platform, source=source, main_action=main_action):
|
||||
cp = forte_params(platform, source)
|
||||
configured = settings(main=main_action)
|
||||
dispatched = create_intent(cp, configured, policy_for(cp))
|
||||
direct = ForteCardIntent(cp, configured)
|
||||
self.assertIs(type(dispatched), ForteCardIntent)
|
||||
for tick, events in enumerate(((), ((Button.lkas, False),), ((Button.lkas, True),),
|
||||
((Button.lkas, False),), ((Button.lkas, True),),
|
||||
((Button.lkas, False),)), 1):
|
||||
cs = forte_state(main=True, cruise=tick > 1, events=events)
|
||||
for intent in (dispatched, direct):
|
||||
intent.update(cs, now_ns=tick)
|
||||
for attribute in ('allowed_latch', 'pause_lateral', 'pause_longitudinal',
|
||||
'physical_latch', 'fault_rearm', 'neutral_seen'):
|
||||
self.assertEqual(getattr(dispatched, attribute), getattr(direct, attribute), attribute)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Unknown vehicles have no implicit AOL capability or native receipt."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openpilot.starpilot.aol.vehicle import AolVehiclePolicy, native_matches_cp, native_profile_supported, policy_for
|
||||
|
||||
|
||||
class GenericVehiclePolicyTests(unittest.TestCase):
|
||||
def test_unknown_and_malformed_cp_fail_closed(self):
|
||||
denied = AolVehiclePolicy()
|
||||
for cp in (None, SimpleNamespace(), SimpleNamespace(brand='ford'),
|
||||
SimpleNamespace(brand='honda'), SimpleNamespace(brand='hyundai')):
|
||||
with self.subTest(cp=cp):
|
||||
self.assertEqual(policy_for(cp), denied)
|
||||
self.assertFalse(native_matches_cp(cp, 0, 0))
|
||||
|
||||
def test_no_unregistered_native_profile(self):
|
||||
self.assertFalse(native_profile_supported(0, 0))
|
||||
self.assertFalse(native_profile_supported(999, 0x8815))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
"""Fail-closed dispatch to vehicle-owned AOL capabilities."""
|
||||
|
||||
from openpilot.starpilot.aol.policy import AolVehiclePolicy
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent
|
||||
from openpilot.starpilot.car.honda import aol as honda
|
||||
from openpilot.starpilot.car.hyundai import aol as hyundai
|
||||
from openpilot.starpilot.car.gm import aol as gm
|
||||
from openpilot.starpilot.car.ford import aol as ford
|
||||
from openpilot.starpilot.car.mazda import aol as mazda
|
||||
from openpilot.starpilot.car.tesla import aol as tesla
|
||||
from openpilot.starpilot.car.toyota import aol as toyota
|
||||
|
||||
_PORTS = {'honda': honda, 'hyundai': hyundai, 'gm': gm, 'ford': ford, 'mazda': mazda, 'tesla': tesla, 'toyota': toyota}
|
||||
|
||||
|
||||
def policy_for(CP) -> AolVehiclePolicy:
|
||||
try:
|
||||
port = _PORTS.get(CP.brand)
|
||||
return port.policy_for(CP) if port is not None else AolVehiclePolicy()
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return AolVehiclePolicy()
|
||||
|
||||
|
||||
def native_profile_supported(model: int, param: int) -> bool:
|
||||
return any(port.native_profile_supported(model, param) for port in _PORTS.values())
|
||||
|
||||
|
||||
def native_matches_cp(CP, model: int, param: int) -> bool:
|
||||
try:
|
||||
if CP.brand == 'hyundai':
|
||||
config = hyundai.aol_safety_config(CP)
|
||||
return bool(config is not None and hyundai.native_accepts_cp(CP, model, param) and
|
||||
model == int(config.safetyModel.raw) and param == int(config.safetyParam))
|
||||
return bool(any(port.native_accepts_cp(CP, model, param) for port in _PORTS.values()) and
|
||||
model == int(CP.safetyConfigs[0].safetyModel.raw) and
|
||||
param == int(CP.safetyConfigs[-1].safetyParam))
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
def native_admission_key(CP):
|
||||
"""Observation reuse is scoped to the exact Ioniq single-Panda contract."""
|
||||
try:
|
||||
return hyundai.native_admission_key(CP) if CP.brand == 'hyundai' else None
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def native_latch_rejected(CP, native, *, state=None) -> bool:
|
||||
if native is None:
|
||||
return False
|
||||
try:
|
||||
port = _PORTS.get(CP.brand)
|
||||
retain = getattr(port, 'retain_on_native_denial', None)
|
||||
rejected = getattr(port, 'native_latch_rejected', None)
|
||||
if retain is None and rejected is None:
|
||||
return False
|
||||
matched = native_matches_cp(CP, native.safetyModel, native.safetyParam)
|
||||
if retain is not None and matched and retain(CP, native, state):
|
||||
return False
|
||||
return bool(rejected is not None and matched and rejected(CP, native))
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
def create_intent(CP, settings, policy):
|
||||
port = _PORTS.get(CP.brand)
|
||||
factory = getattr(port, 'create_intent', None)
|
||||
if factory is not None:
|
||||
return factory(CP, settings)
|
||||
return AolCardIntent(settings, explicit_latch=policy.explicit_latch)
|
||||
|
||||
|
||||
def ordinary_axis_request_allowed(CP, CS) -> bool:
|
||||
"""Required ordinary transport has no permission without its port owner."""
|
||||
try:
|
||||
port = _PORTS.get(CP.brand)
|
||||
allowed = getattr(port, 'ordinary_axis_request_allowed', None)
|
||||
return bool(allowed is not None and allowed(CP, CS))
|
||||
except (AttributeError, TypeError, ValueError, OverflowError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
def allow_lateral_onset(CP, *, requested, normal_enabled, steering_pressed, previous_active):
|
||||
"""Optional vehicle-owned onset restriction; never creates permission."""
|
||||
port = _PORTS.get(CP.brand)
|
||||
owner = getattr(port, 'allow_lateral_onset', None)
|
||||
if owner is None:
|
||||
return bool(requested)
|
||||
return bool(requested and owner(CP, requested=requested, normal_enabled=normal_enabled,
|
||||
steering_pressed=steering_pressed, previous_active=previous_active))
|
||||
|
||||
|
||||
def configuration_settings_policy(CP):
|
||||
from openpilot.starpilot.car.hyundai.settings import configuration_wheel_policy
|
||||
return configuration_wheel_policy(CP) or toyota.configuration_settings_policy(CP) or gm.configuration_settings_policy(CP)
|
||||
@@ -1,15 +1,20 @@
|
||||
"""GM-owned AOL capabilities; exact CP admission lives with the vehicle."""
|
||||
|
||||
import math
|
||||
|
||||
from opendbc.car.structs import CarParams
|
||||
from opendbc.car.gm.values import CAR, control_flags, camera_acc_pedal_profile, is_volt_ascm_longitudinal
|
||||
from opendbc.car.gm.aol import GM_AOL_ALTERNATIVE_EXPERIENCE, GM_AOL_WORDS, qualified_gm
|
||||
from openpilot.starpilot.aol.policy import AolVehiclePolicy
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent
|
||||
from openpilot.starpilot.aol.intent import AOL_TOGGLE, AolCardIntent
|
||||
|
||||
|
||||
def policy_for(cp) -> AolVehiclePolicy:
|
||||
qualified = qualified_gm(cp)
|
||||
return AolVehiclePolicy(intent_supported=qualified, settings_supported=qualified,
|
||||
runtime_supported=qualified, normal_runtime_supported=qualified,
|
||||
lkas_button_supported=False,
|
||||
full_axis_runtime_required=qualified and int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE,
|
||||
alternative_experience_addition=GM_AOL_ALTERNATIVE_EXPERIENCE if qualified else 0)
|
||||
|
||||
|
||||
@@ -25,10 +30,13 @@ def native_accepts_cp(cp, model: int, param: int) -> bool:
|
||||
class GmAolCardIntent(AolCardIntent):
|
||||
"""Main-following intent with explicit main-cycle recovery after fatal faults."""
|
||||
|
||||
def __init__(self, settings):
|
||||
super().__init__(settings)
|
||||
def __init__(self, settings, cp=None):
|
||||
super().__init__(settings, lkas_button_supported=False)
|
||||
self.cp = cp
|
||||
self.requires_fault_observation = True
|
||||
self._main_cycle_required = False
|
||||
self.startup_calibration_pending = True
|
||||
self._startup_incomplete = False
|
||||
|
||||
def update(self, CS, *, consumed_buttons=frozenset(), fault_active=None,
|
||||
now_ns=0, native_rejection_ns=0, standard_enabled=False):
|
||||
@@ -42,7 +50,229 @@ class GmAolCardIntent(AolCardIntent):
|
||||
standard_enabled=standard_enabled)
|
||||
if self._main_cycle_required:
|
||||
self.allowed_latch = False
|
||||
self.startup_calibration_pending = False
|
||||
|
||||
def observe_calibration(self, CS, *, events=None, standard_enabled=False):
|
||||
from openpilot.cereal import log
|
||||
# Cold calibration readiness is not a previously revoked driver gesture.
|
||||
# The original calibration inhibit still withdraws all optional output.
|
||||
prior_incomplete = self._startup_incomplete
|
||||
cold_safe = True
|
||||
if events is not None:
|
||||
from openpilot.starpilot.aol.intent import disarming_fault
|
||||
remaining = [event for event in _intent_fault_events(self.cp, events) if
|
||||
event.name != log.OnroadEvent.EventName.calibrationIncomplete or event.immediateDisable]
|
||||
cold_safe = not disarming_fault(remaining, CS,
|
||||
temporary_selfdrive_lagging=qualified_gm(self.cp))
|
||||
self._startup_incomplete = bool(cold_safe and
|
||||
any(event.name == log.OnroadEvent.EventName.calibrationIncomplete for event in events))
|
||||
cold_readiness = bool(self.startup_calibration_pending and cold_safe and
|
||||
(prior_incomplete or self._startup_incomplete) and
|
||||
self.settings.enabled and self.settings.main_action != AOL_TOGGLE and
|
||||
not CS.steerFaultPermanent and not self._main_cycle_required)
|
||||
if cold_readiness:
|
||||
self._calibration_rearm_required = False
|
||||
super().observe_calibration(CS, events=events, standard_enabled=standard_enabled)
|
||||
if cold_readiness:
|
||||
self._calibration_rearm_required = False
|
||||
if (events is not None and CS.canValid and not CS.canTimeout and not self._startup_incomplete and
|
||||
not any(event.name == log.OnroadEvent.EventName.selfdriveInitializing for event in events)):
|
||||
self.startup_calibration_pending = False
|
||||
|
||||
def output(self, CS):
|
||||
allowed, pause_lateral, pause_longitudinal = super().output(CS)
|
||||
return (bool(allowed and not CS.canTimeout and CS.cruiseState.available),
|
||||
pause_lateral, pause_longitudinal)
|
||||
|
||||
|
||||
def create_intent(cp, settings):
|
||||
return GmAolCardIntent(settings) if qualified_gm(cp) else None
|
||||
return GmAolCardIntent(settings, cp) if qualified_gm(cp) else None
|
||||
|
||||
|
||||
def configuration_settings_policy(cp):
|
||||
"""Saving stock Silverado lateral intent does not establish its physical source graph."""
|
||||
try:
|
||||
if (cp.brand == 'gm' and cp.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC and
|
||||
not cp.notCar and not cp.passive and cp.lateralTuning.which() == 'torque' and
|
||||
cp.steerControlType == CarParams.SteerControlType.torque and
|
||||
int(cp.alternativeExperience) in (0, GM_AOL_ALTERNATIVE_EXPERIENCE)):
|
||||
return AolVehiclePolicy(settings_supported=True, lkas_button_supported=False)
|
||||
if (cp.brand != 'gm' or cp.carFingerprint != CAR.CHEVROLET_SILVERADO_CC or
|
||||
cp.notCar or cp.passive or cp.networkLocation != CarParams.NetworkLocation.gateway or
|
||||
cp.transmissionType != CarParams.TransmissionType.automatic or not cp.radarUnavailable or
|
||||
not cp.pcmCruise or cp.openpilotLongitudinalControl or cp.alphaLongitudinalAvailable or
|
||||
control_flags(cp) != 0 or int(cp.alternativeExperience) not in (0, GM_AOL_ALTERNATIVE_EXPERIENCE) or
|
||||
len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyModel != CarParams.SafetyModel.gm or
|
||||
int(cp.safetyConfigs[0].safetyParam) != 16 or cp.steerControlType != CarParams.SteerControlType.torque or
|
||||
cp.lateralTuning.which() != 'torque'):
|
||||
return None
|
||||
tune = cp.lateralTuning.torque
|
||||
if (not all(math.isfinite(value) for value in (tune.latAccelFactor, tune.latAccelOffset, tune.friction)) or
|
||||
tune.latAccelFactor <= 0 or tune.friction < 0):
|
||||
return None
|
||||
return AolVehiclePolicy(settings_supported=True, lkas_button_supported=False)
|
||||
except (AttributeError, IndexError, TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _intent_fault_events(cp, events):
|
||||
from openpilot.cereal import log
|
||||
if qualified_gm(cp):
|
||||
availability = (log.OnroadEvent.EventName.commIssue, log.OnroadEvent.EventName.commIssueAvgFreq)
|
||||
events = [event for event in events if not
|
||||
(event.name in availability and event.softDisable and not event.immediateDisable)]
|
||||
if qualified_gm(cp) and camera_acc_pedal_profile(cp) is None and is_volt_ascm_longitudinal(cp):
|
||||
events = [event for event in events if event.name != log.OnroadEvent.EventName.accFaulted]
|
||||
return events
|
||||
|
||||
|
||||
def intent_disarming_fault(cp, events, state, **context):
|
||||
"""An ASCM Volt ACC fault withdraws axes without erasing main-armed intent."""
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.aol.intent import disarming_fault
|
||||
cold_calibration = context.pop('startup_calibration_pending', False)
|
||||
if qualified_gm(cp) and cold_calibration and not state.steerFaultPermanent:
|
||||
events = [event for event in events if not (
|
||||
event.name == log.OnroadEvent.EventName.calibrationIncomplete and not event.immediateDisable)]
|
||||
return disarming_fault(_intent_fault_events(cp, events), state, **context)
|
||||
|
||||
|
||||
def physical_longitudinal_withdrawal_source(cp, cs, parser, observation, now_ns) -> int:
|
||||
"""Bind a Bolt pedal-owner withdrawal to its parsed CAN and local receipt."""
|
||||
from opendbc.car.gm.values import CruiseButtons, is_bolt_present_no_acc_pedal_profile
|
||||
from opendbc.car.structs import car
|
||||
try:
|
||||
if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or
|
||||
int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or
|
||||
observation is None or observation[0] is not cs or not cs.canValid or cs.canTimeout):
|
||||
return 0
|
||||
fields = []
|
||||
if cs.brakePressed and parser.vl['ECMEngineStatus']['BrakePressed']:
|
||||
fields.append(('ECMEngineStatus', 'BrakePressed'))
|
||||
if cs.regenBraking and parser.vl['EBCMRegenPaddle']['RegenPaddle']:
|
||||
fields.append(('EBCMRegenPaddle', 'RegenPaddle'))
|
||||
if (parser.vl['ASCMSteeringButton']['ACCButtons'] == CruiseButtons.CANCEL and
|
||||
any(e.type == car.CarState.ButtonEvent.Type.cancel and e.pressed for e in cs.buttonEvents)):
|
||||
fields.append(('ASCMSteeringButton', 'ACCButtons'))
|
||||
_, received_ns, can_source_ns, boot_ns = observation
|
||||
if not fields or not 0 < received_ns <= now_ns or now_ns - received_ns > 30_000_000:
|
||||
return 0
|
||||
# Parser/CAN batch use BOOTTIME. The fixed deadline uses the original
|
||||
# local MONOTONIC receipt; neither clock is converted or restamped.
|
||||
stamps = [int(parser.ts_nanos[message][signal]) for message, signal in fields]
|
||||
if not all(0 < stamp <= can_source_ns <= boot_ns and boot_ns - stamp <= 30_000_000 and
|
||||
can_source_ns - stamp <= 30_000_000 for stamp in stamps):
|
||||
return 0
|
||||
return received_ns
|
||||
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def retained_physical_longitudinal_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, source_ns, producer):
|
||||
"""Retain accepted LAT intent through a bounded physical LONG withdrawal."""
|
||||
from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS
|
||||
from openpilot.starpilot.aol.transport_pause import independent_lateral_health
|
||||
occurrence = feedback.gm_withdrawal_ns or source_ns
|
||||
if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or
|
||||
int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or
|
||||
not latched or feedback.failed or feedback.paused or feedback.pending_event or
|
||||
feedback.retention_started_ns or not feedback.session or str(axis.sessionId) != feedback.session or
|
||||
not feedback.healthy_feedback_ns or not 0 <= now_ns - feedback.healthy_feedback_ns <= AXIS_MAX_AGE_NS or
|
||||
not 0 < occurrence <= now_ns or now_ns - occurrence > SAFETY_MAX_AGE_NS or
|
||||
not feedback.gm_withdrawal_ns and now_ns - occurrence > AXIS_MAX_AGE_NS or
|
||||
not producer or producer != feedback.availability_producer or
|
||||
str(axis.intentProducerSessionId) != producer or int(axis.intentIntegrityEpoch) != feedback.availability_epoch or
|
||||
cs.accFaulted or cs.doorOpen or not cs.cruiseState.available or
|
||||
cs.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
|
||||
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) or
|
||||
str(axis.faultReason) != 'none' or not axis.desiredLateral or not axis.lateralActive or
|
||||
not axis.nativeAcknowledged or
|
||||
not 0 < axis.sourceCarStateMonoTime <= axis.observedMonoTime <= now_ns or
|
||||
now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS):
|
||||
return None
|
||||
# The saved LAT contract treats a parsed unlatched belt as a LONG lock.
|
||||
# After rebuckling, accept only a prior producer's matching belt event and
|
||||
# LAT-only proof within the already established physical occurrence. Events
|
||||
# publish sparsely, so their stamp binds to faultEventMonoTime, not the newer
|
||||
# car-state source. The original axis/native leases remain unchanged.
|
||||
event_ns = int(sm.logMonoTime['onroadEvents'])
|
||||
prior_belt_proof = bool(feedback.gm_withdrawal_ns and
|
||||
feedback.gm_withdrawal_ns <= event_ns <= axis.observedMonoTime < now_ns and
|
||||
event_ns == axis.faultEventMonoTime and str(axis.faultSessionId) == feedback.session and
|
||||
int(sm.logMonoTime['aolAxisState']) == axis.observedMonoTime and
|
||||
now_ns <= axis.validUntilMonoTime <= axis.observedMonoTime + AXIS_MAX_AGE_NS and
|
||||
not axis.desiredLongitudinal and not axis.longitudinalActive)
|
||||
if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or
|
||||
not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or
|
||||
any(e.immediateDisable or e.softDisable and not
|
||||
((cs.seatbeltUnlatched or prior_belt_proof) and e.name == log.OnroadEvent.EventName.seatbeltNotLatched and
|
||||
e.noEntry and not e.userDisable) for e in sm['onroadEvents'])):
|
||||
return None
|
||||
native = independent_lateral_health(sm, cp, cs, feedback.session, now_ns)
|
||||
if (native is None or not native.pandaInventory or not native.requestedLongitudinal or
|
||||
not any(not p.controlsAllowed for p in sm['pandaStates']) or
|
||||
native.observedMonoTime < feedback.healthy_native_ns or
|
||||
(native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != feedback.healthy_inventory):
|
||||
return None
|
||||
# One occurrence spans the held native request. Held/released samples and
|
||||
# duplicate Card calls cannot renew it; output/native leases remain original.
|
||||
feedback.gm_withdrawal_ns = occurrence
|
||||
return native
|
||||
|
||||
|
||||
def physical_cancel_lateral_allowed(cp, cs, intent, native, *, source_ns, now_ns) -> bool:
|
||||
"""Exact armed Bolt Cancel removes only the lateral NO_ENTRY restriction."""
|
||||
from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
|
||||
try:
|
||||
return bool(qualified_gm(cp) and is_bolt_present_no_acc_pedal_profile(cp) and
|
||||
int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and
|
||||
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
|
||||
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
|
||||
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - source_ns <= AXIS_MAX_AGE_NS and
|
||||
# Card declares a 200ms wire lease; admission and this source remain 30ms fresh.
|
||||
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
|
||||
cs.canValid and not cs.canTimeout and not cs.steerFaultTemporary and not cs.steerFaultPermanent and
|
||||
any(e.type == car.CarState.ButtonEvent.Type.cancel for e in cs.buttonEvents) and
|
||||
native is not None and native.requestedLateral and native.lateralAllowed and
|
||||
native.pandaInventory and native_inventory_matches_cp(native, cp) and
|
||||
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime)
|
||||
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source_ns, now_ns, axis_session_id) -> bool:
|
||||
"""The qualified saved LAT owner treats only its ordinary belt event as a LONG lock."""
|
||||
if not qualified_gm(cp):
|
||||
return False
|
||||
from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.cereal import log
|
||||
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
|
||||
try:
|
||||
return bool(is_bolt_present_no_acc_pedal_profile(cp) and
|
||||
int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and
|
||||
event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and
|
||||
event.noEntry and not event.immediateDisable and not event.userDisable and
|
||||
cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and
|
||||
not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and
|
||||
cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
|
||||
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and
|
||||
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
|
||||
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
|
||||
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
|
||||
now_ns - source_ns <= AXIS_MAX_AGE_NS and
|
||||
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
|
||||
native is not None and native.compatible and int(native.protocolVersion) == 1 and
|
||||
axis_session_id and native.axisSessionId == axis_session_id and
|
||||
native.requestedLateral and native.lateralAllowed and native.pandaInventory and
|
||||
native_inventory_matches_cp(native, cp) and
|
||||
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and
|
||||
native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS)
|
||||
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Exact carState companions from the nonconflating steering-authority transport."""
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS
|
||||
|
||||
MAX_COMPANIONS = 16
|
||||
MAX_DRAIN = 32
|
||||
SERVICE = 'starpilotCarState'
|
||||
|
||||
|
||||
class SteeringCompanion:
|
||||
def __init__(self, sock=None):
|
||||
self.sock = sock
|
||||
self.samples = {}
|
||||
self.latest = None
|
||||
|
||||
def _observe(self, stamp, valid, companion):
|
||||
sample = (int(stamp), bool(valid), int(companion.sourceCarStateMonoTime),
|
||||
bool(companion.lateralAuthorityUnavailable))
|
||||
previous = self.samples.get(sample[0])
|
||||
if previous is not None:
|
||||
sample = (sample[0], previous[1] and sample[1] and previous[2] == sample[2],
|
||||
sample[2], previous[3] or sample[3])
|
||||
self.samples[sample[0]] = sample
|
||||
if self.latest is None or sample[0] >= self.latest[0]:
|
||||
self.latest = sample
|
||||
while len(self.samples) > MAX_COMPANIONS:
|
||||
del self.samples[min(self.samples)]
|
||||
|
||||
def current(self, sm, *, source_ns, now_ns):
|
||||
if self.sock is not None:
|
||||
for _ in range(MAX_DRAIN):
|
||||
message = messaging.recv_one_or_none(self.sock)
|
||||
if message is None:
|
||||
break
|
||||
self._observe(message.logMonoTime, message.valid, message.starpilotCarState)
|
||||
else:
|
||||
return None
|
||||
elif sm.seen.get(SERVICE, False):
|
||||
self._observe(sm.logMonoTime[SERVICE], sm.valid.get(SERVICE, False), sm[SERVICE])
|
||||
|
||||
for stamp in tuple(self.samples):
|
||||
if not 0 < stamp <= now_ns or now_ns - stamp > INTENT_MAX_AGE_NS:
|
||||
del self.samples[stamp]
|
||||
if not (sm.seen.get(SERVICE, False) and sm.valid.get(SERVICE, False) and sm.alive.get(SERVICE, False)):
|
||||
return None
|
||||
sm_stamp = int(sm.logMonoTime[SERVICE])
|
||||
sm_companion = sm[SERVICE]
|
||||
if not (int(sm_companion.sourceCarStateMonoTime) == sm_stamp and
|
||||
0 < source_ns <= sm_stamp <= now_ns and now_ns - sm_stamp <= INTENT_MAX_AGE_NS):
|
||||
return None
|
||||
latest = self.latest
|
||||
pair = self.samples.get(source_ns)
|
||||
if latest is None or pair is None:
|
||||
return None
|
||||
stamp, valid, source, unavailable = latest
|
||||
if not (valid and source == stamp and source_ns <= stamp <= now_ns and
|
||||
now_ns - stamp <= INTENT_MAX_AGE_NS and pair[1] and pair[2] == source_ns):
|
||||
return None
|
||||
return bool(pair[3] or unavailable or sm_companion.lateralAuthorityUnavailable)
|
||||
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from opendbc.car.car_helpers import interfaces
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.controls.controlsd import Controls
|
||||
from openpilot.starpilot.car.hyundai.lateral_fault import LateralFaultLatch
|
||||
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed
|
||||
|
||||
|
||||
class TestHyundaiLateralFault(unittest.TestCase):
|
||||
def test_actual_controls_requires_rearm_after_elantra_hybrid_fault(self):
|
||||
for model in (CAR.HYUNDAI_ELANTRA_HEV_2024, CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_IONIQ_6):
|
||||
with self.subTest(model=model), OpenpilotPrefix(), \
|
||||
patch.dict(os.environ, {'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'):
|
||||
cp = interfaces[model].get_non_essential_params(model)
|
||||
Params().put('CarParams', cp.to_bytes(), block=True)
|
||||
controls = Controls()
|
||||
latched_model = model == CAR.HYUNDAI_ELANTRA_HEV_2024
|
||||
cases = [(True, False, False, False, True),
|
||||
(True, True, False, False, False),
|
||||
(True, False, False, False, not latched_model),
|
||||
(True, False, True, False, True),
|
||||
(True, True, True, False, False),
|
||||
(False, False, False, False, False),
|
||||
(True, False, False, False, True),
|
||||
(True, False, False, True, False)]
|
||||
for tick, (requested, temporary, cruise, permanent, expected) in enumerate(cases):
|
||||
now_ns = 1_000_000_000 + tick * 10_000_000
|
||||
feed(controls, now_ns, tick, active=requested, enabled=requested, fault=temporary)
|
||||
event = messaging.new_message('carState', valid=True, logMonoTime=now_ns)
|
||||
event.carState = controls.sm['carState']
|
||||
event.carState.cruiseState.enabled = cruise
|
||||
event.carState.steerFaultPermanent = permanent
|
||||
controls.sm.data['carState'] = event.carState.as_reader()
|
||||
command, _ = controls.state_control()
|
||||
self.assertEqual(command.latActive, expected)
|
||||
self.assertEqual(command.enabled, requested)
|
||||
if not expected:
|
||||
self.assertEqual(command.actuators.torque, 0.0)
|
||||
|
||||
def test_actual_axis_intent_survives_fault_and_missing_permission_without_admission(self):
|
||||
from openpilot.starpilot.aol.runtime import current_axis, current_native
|
||||
|
||||
with OpenpilotPrefix(), patch.dict(os.environ, {'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'):
|
||||
cp = interfaces[CAR.HYUNDAI_ELANTRA_HEV_2024].get_non_essential_params(CAR.HYUNDAI_ELANTRA_HEV_2024)
|
||||
Params().put('CarParams', cp.to_bytes(), block=True)
|
||||
controls = Controls()
|
||||
self.assertFalse(controls.aol_replay)
|
||||
# Exercise the reader boundary without granting this vehicle AOL safety.
|
||||
controls.aol_replay = True
|
||||
controls.sm = messaging.SubMaster([*controls.sm.services, 'aolAxisState', 'aolSafetyWire'], frequency=100)
|
||||
for tick, (desired, active, fault, fresh, expected_latch) in enumerate(
|
||||
((True, True, True, True, True), (True, False, False, True, True),
|
||||
(True, False, False, False, True), (False, False, False, True, False))):
|
||||
now_ns = 1_000_000_000 + tick * 10_000_000
|
||||
feed(controls, now_ns, tick, fault=fault, aol_lat_only=True)
|
||||
event = messaging.new_message('aolAxisState', valid=True, logMonoTime=now_ns)
|
||||
event.aolAxisState = controls.sm['aolAxisState']
|
||||
event.aolAxisState.desiredLateral = desired
|
||||
event.aolAxisState.lateralActive = active
|
||||
controls.sm.data['aolAxisState'] = event.aolAxisState.as_reader()
|
||||
controls.sm.valid['aolAxisState'] = fresh
|
||||
axis = current_axis(controls.sm, now_ns=now_ns)
|
||||
self.assertEqual(axis is not None, fresh)
|
||||
self.assertIsNone(current_native(controls.sm, cp, now_ns=now_ns))
|
||||
command, _ = controls.state_control()
|
||||
self.assertEqual(controls.hyundai_lateral_fault.faulted, expected_latch)
|
||||
self.assertFalse(command.latActive)
|
||||
self.assertEqual(command.actuators.torque, 0.0)
|
||||
|
||||
def test_fault_at_cruise_rising_edge_still_suppresses_output(self):
|
||||
latch = LateralFaultLatch(CAR.HYUNDAI_ELANTRA_HEV_2024)
|
||||
self.assertFalse(latch.update(requested=True, temporary_fault=True, cruise_enabled=True))
|
||||
self.assertTrue(latch.update(requested=True, temporary_fault=True, cruise_enabled=True))
|
||||
self.assertTrue(latch.update(requested=True, temporary_fault=False, cruise_enabled=True))
|
||||
self.assertFalse(latch.update(requested=False, temporary_fault=False, cruise_enabled=True))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user