Files
StarPilot/openpilot/starpilot/tests/test_ford_bluepilot_aol.py
T

437 lines
22 KiB
Python

"""Real Ford factory/Card/SelfdriveD/Controls/native combined-owner lifecycle."""
import pytest
from opendbc.car.ford.values import CAR
from openpilot.starpilot.tests.ford_lifecycle_helpers import ford_joined as ford_joined, run_actual_producer
@pytest.fixture(autouse=True)
def combined_angle(monkeypatch, topology):
monkeypatch.ford_angle = True
monkeypatch.ford_topology = topology
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1,18), (CAR.FORD_ESCAPE_MK4,32), (CAR.FORD_F_150_MK14,66)])
@pytest.mark.parametrize('alpha', [False,True])
@pytest.mark.parametrize('hands_free', [False,True])
@pytest.mark.parametrize('topology', ['automatic','manual-multi'])
def test_angle_aol_brake_reverse_fault_and_hands_free(ford_joined,monkeypatch):
assert ford_joined.host.CI.CC.bp_lat is not None
run_actual_producer(ford_joined,monkeypatch,scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1,18)])
@pytest.mark.parametrize('alpha', [False,True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
def test_mache_nonzero_angle_after_brake_with_hands_free(ford_joined,monkeypatch):
monkeypatch.ford_nonzero = True
run_actual_producer(ford_joined,monkeypatch,scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_tight_turn_speed_window_stays_inside_native_caps(ford_joined, monkeypatch):
# Route19's first blocked frames exceed Panda's cap by using the latest
# speed instead of its six-sample minimum. Exercise real parser/native windows.
monkeypatch.ford_nonzero = True
monkeypatch.ford_speed_window = True
monkeypatch.ford_test_curvature = .03
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
def test_real_rejected_packet_resynchronizes_without_permission_bypass(ford_joined, monkeypatch):
from types import SimpleNamespace
from opendbc.car import structs
from opendbc.safety.tests.libsafety import libsafety_py
joined = ford_joined
native, safety = joined.native, joined.safety
native.arm()
adapter = joined.host.CI.CC.bp_lat
inputs = adapter.host.inputs
blocked = 0
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
# Isolate the adapter recovery while preserving actual physical native authority.
# The full caller matrix above retains real wire/inventory/freshness qualification.
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
CC = structs.CarControl(enabled=True, latActive=True)
CC.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
CS = SimpleNamespace(out=state, lat_ctl_lim_stat=0)
rejected = recovered = False
neutral_after_rejection = False
for frame in range(180):
native.pump(1, mask=1, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
from openpilot.cereal import messaging
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now)
panda.pandaStates[0].safetyTxBlocked = blocked
inputs.sm.update_msgs(now / 1e9, [panda.as_reader()])
packets = []
if frame % 5 == 0:
packets += adapter.step_lateral(CC, CS, native.packer, joined.host.CI.CC.CAN, frame, now)
if frame % 3 == 0:
packets += adapter.step_lka(native.packer, joined.host.CI.CC.CAN, now)
for address, original, bus in packets:
data = bytearray(original)
active = address == 0x3D6 and (data[0] >> 4) & 7 == 1
if active and frame >= 80 and not rejected:
data[1] ^= 1 # One actual bad checksum; native must reject it.
assert not safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
blocked += 1
# Feed the real rejected CAN return, not just the aggregate counter.
adapter.observe_can([(now, [(address, bytes(data), bus | 0xC0)])], bus)
rejected = True
continue
accepted = safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
assert accepted, (frame, hex(address), data.hex())
if rejected and address == 0x3D6:
neutral_after_rejection |= not active
recovered |= active and neutral_after_rejection
assert rejected and neutral_after_rejection and recovered
# Host recovery never replaces native authorization.
safety.aol_set_host_request(0)
native.pump(5, mask=0, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
neutral = adapter.step_lateral(CC, CS, native.packer, joined.host.CI.CC.CAN, 180, now)[0]
assert (neutral[1][0] >> 4) & 7 == 0
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_native_first_pedal_preserves_only_granted_lateral(ford_joined, monkeypatch):
monkeypatch.ford_nonzero = True
monkeypatch.ford_native_first_pedal = True
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_early_pedal_admission_requires_exact_marked_angle_long_profile(ford_joined):
from opendbc.car import structs
from openpilot.starpilot.car.ford.aol import early_native_long_withdrawal_supported
cp = ford_joined.host.CP
assert early_native_long_withdrawal_supported(cp)
for field, value in (('alternativeExperience', 0), ('openpilotLongitudinalControl', False),
('passive', True), ('dashcamOnly', True), ('brand', 'hyundai')):
changed = structs.CarParams(**cp.to_dict())
setattr(changed, field, value)
assert not early_native_long_withdrawal_supported(changed)
for mask in (128, 1, 16):
changed = structs.CarParams(**cp.to_dict())
changed.safetyConfigs[-1].safetyParam ^= mask
assert not early_native_long_withdrawal_supported(changed)
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [False, True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_temporary_eps_recovery_with_gas_preserves_only_lateral_intent(ford_joined, monkeypatch):
# Route3b: cruise is off, gas remains pressed through a 150 ms EPS pulse.
# The actual native hook must still reject active output during that pulse.
monkeypatch.ford_nonzero = True
monkeypatch.ford_eps_gas = True
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
@pytest.mark.parametrize('delivery', ['paired', 'axis-first', 'event-first', 'never-paired'])
def test_route45_stock_angle_eps_gas_release_join(ford_joined, monkeypatch, delivery):
monkeypatch.ford_nonzero = True
monkeypatch.ford_route45_delivery = delivery
run_actual_producer(ford_joined, monkeypatch, scenario='route45')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
@pytest.mark.parametrize('refusal', ['permanent-eps', 'main-off', 'cancel'])
def test_route45_eps_join_keeps_physical_refusals(ford_joined, monkeypatch, refusal):
monkeypatch.ford_nonzero = True
monkeypatch.ford_route45_delivery = 'axis-first'
monkeypatch.ford_route45_refusal = refusal
run_actual_producer(ford_joined, monkeypatch, scenario='route45')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [False, True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_route22_gas_held_first_engagement_keeps_aol_through_braking(ford_joined, monkeypatch):
from openpilot.starpilot.aol.transport_pause import TransportPauseFeedback
from opendbc.car import structs
class Changed:
def __init__(self, source, **values):
self.source = source
self.__dict__.update(values)
def __getattr__(self, name):
return getattr(self.source, name)
checked = []
original = TransportPauseFeedback._first_ford_gas_lateral_grant
def check_first_grant(feedback, sm, cp, cs, axis, now_ns, *, latched, producer):
result = original(feedback, sm, cp, cs, axis, now_ns, latched=latched, producer=producer)
if result is not None and not checked:
def refuses(*, state=cs, proof=axis, armed=latched, owner=producer, params=cp):
assert original(feedback, sm, params, state, proof, now_ns, latched=armed, producer=owner) is None
for field, value in [('gasPressed', False), ('brakePressed', True), ('regenBraking', True),
('canValid', False), ('canTimeout', True), ('steerFaultTemporary', True),
('steerFaultPermanent', True), ('accFaulted', True), ('doorOpen', True),
('seatbeltUnlatched', True), ('vehicleSensorsInvalid', True),
('steeringDisengage', True), ('gearShifter', structs.CarState.GearShifter.reverse)]:
refuses(state=Changed(cs, **{field: value}))
for field, value in [('intentProducerSessionId', 'other'), ('intentIntegrityEpoch', axis.intentIntegrityEpoch + 1),
('sessionId', 'other'), ('nativeAcknowledged', False), ('lateralActive', False),
('desiredLateral', False), ('longitudinalActive', True), ('desiredLongitudinal', False),
('faultReason', 'controlsMismatch'), ('mode', 'off'),
('sourceCarStateMonoTime', now_ns - 30_000_001), ('validUntilMonoTime', now_ns - 1)]:
refuses(proof=Changed(axis, **{field: value}))
refuses(armed=False)
refuses(owner='other')
refuses(params=Changed(cp, alternativeExperience=0))
for field, value in [('failed', True), ('paused', True), ('pending_event', True),
('healthy_inventory', ('other', ())), ('lateral_baseline_ns', now_ns)]:
with monkeypatch.context() as local:
local.setattr(feedback, field, value)
refuses()
checked.append(True)
return result
monkeypatch.setattr(TransportPauseFeedback, '_first_ford_gas_lateral_grant', check_first_grant)
monkeypatch.ford_nonzero = True
run_actual_producer(ford_joined, monkeypatch, scenario='route22')
assert checked
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
@pytest.mark.parametrize('fault', ['rx-quality', 'source-loss'])
def test_route22_native_fault_still_withdraws_aol(ford_joined, monkeypatch, fault):
monkeypatch.ford_nonzero = True
monkeypatch.ford_route22_final_fault = fault
run_actual_producer(ford_joined, monkeypatch, scenario='route22')
@pytest.mark.parametrize('identity', [(CAR.FORD_F_150_LIGHTNING_MK1, 66), (CAR.FORD_ESCAPE_MK4_5, 66),
(CAR.FORD_EXPEDITION_MK4, 66), (CAR.FORD_F_150_MK14, 66), (CAR.FORD_RANGER_MK2, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [False, True])
@pytest.mark.parametrize('topology', ['automatic'])
def test_lightning_cluster_startup_and_aol_recovery(ford_joined, monkeypatch):
monkeypatch.ford_nonzero = True
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_F_150_LIGHTNING_MK1, 66)])
@pytest.mark.parametrize('alpha', [False])
@pytest.mark.parametrize('hands_free', [False, True])
@pytest.mark.parametrize('topology', ['automatic'])
@pytest.mark.parametrize('armed', [False, True])
def test_lightning_unarmed_startup_does_not_show_armed_eps_warning(ford_joined, monkeypatch, armed):
monkeypatch.ford_startup_armed = armed
run_actual_producer(ford_joined, monkeypatch, scenario='startup-warning')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
@pytest.mark.parametrize('can_clock_offset_ns', [0, 9_000_000_000])
def test_angle_transport_batching_keeps_native_spacing(ford_joined, monkeypatch, can_clock_offset_ns):
from opendbc.car import structs
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
joined = ford_joined
native, safety, ci = joined.native, joined.safety, joined.host.CI
native.arm()
adapter = ci.CC.bp_lat
inputs = adapter.host.inputs
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
control = structs.CarControl(enabled=True, latActive=True)
control.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
queue, echoes, rejected = [], [], []
active_count = 0
control_address = 0x3D6 if adapter.canfd else 0x3D3
last_delivery = 0
for frame in range(300):
now_us = 1_000_000 + (native.tick + 1) * 10_000
while queue and queue[0][0] <= now_us:
delivered, address, data, bus = queue.pop(0)
safety.set_timer(delivered)
accepted = safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
if not accepted:
rejected.append((frame, hex(address), data.hex()))
elif address == control_address:
active_count += int(((data[0] >> 4) & 7) == 1) if adapter.canfd else int(bool(data[4] & 4))
echoes.append((delivered * 1000 + can_clock_offset_ns, [(address, data, bus | (0x80 if accepted else 0xC0))]))
native.pump(1, mask=1, speed=15.)
# Exercise the actual interface-to-controller feedback path, including Panda
# bus offsets. Only echoes produced by real native TX acceptance count.
ci.update(echoes)
echoes.clear()
ci.CS.out = state
now_ns = now_us * 1000
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now_ns)
panda.pandaStates[0].safetyTxBlocked = len(rejected)
inputs.sm.update_msgs(now_ns / 1e9, [panda.as_reader()])
_, packets = ci.CC.update(control.as_reader(), ci.CS, now_ns)
for address, data, bus in packets:
if address not in (control_address, 0x3CA):
continue
# A delayed FIFO batch followed by an immediate one compresses a nominal
# 50/30ms send cadence below Panda's unchanged 40/20ms minima.
delivered = max(last_delivery, now_us + (25_000 if frame % 10 == 5 else 0))
last_delivery = delivered
queue.append((delivered, address, data, bus))
assert not rejected, rejected[:8]
assert active_count > 25
safety.aol_set_host_request(0)
native.pump(10, mask=0, speed=15.)
ci.CS.out = state
now_ns = (1_000_000 + native.tick * 10_000) * 1000
packets = adapter.step_lateral(control, ci.CS, native.packer, ci.CC.CAN, 310, now_ns)
assert packets and safety.aol_get_permission_mask() == 0
for address, data, bus in packets:
assert safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_stock_forwarding_returns_cannot_starve_angle_reengagement(ford_joined, monkeypatch):
from opendbc.car import structs
from opendbc.car.ford import fordcan
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
joined = ford_joined
native, safety, ci = joined.native, joined.safety, joined.host.CI
native.arm()
adapter = ci.CC.bp_lat
inputs = adapter.host.inputs
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
command = structs.CarControl(enabled=True, latActive=True)
command.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
echoes = []
active = [0, 0]
forwarded = 0
neutral_seen = False
# Genuine camera frames are forwarded only while the real native hook permits
# it. Those forwarded TX returns share the src|0x80 namespace with host TX.
for frame in range(300):
native.pump(1, mask=1, speed=15.)
now_ns = (1_000_000 + native.tick * 10_000) * 1000
if neutral_seen and frame % 5 == 1:
stock = fordcan.create_lat_ctl2_msg(native.packer, ci.CC.CAN, 0, 0., 0., 0., 0., (frame // 5) % 16)
if safety.safety_fwd_hook(2, stock[0]) == 0:
echoes.append((now_ns, [(stock[0], stock[1], ci.CC.CAN.main | 0x80)]))
forwarded += 1
ci.update(echoes)
echoes.clear()
ci.CS.out = state
# Preserve native permission while exercising one normal host neutral pause.
# Continued forwarding must not prevent a later real active command.
command.latActive = not 80 <= frame < 130
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now_ns)
panda.pandaStates[0].safetyTxBlocked = 0
inputs.sm.update_msgs(now_ns / 1e9, [panda.as_reader()])
_, packets = ci.CC.update(command.as_reader(), ci.CS, now_ns)
for address, data, bus in packets:
assert safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
echoes.append((now_ns + 1, [(address, data, bus | 0x80)]))
if address == 0x3D6:
is_active = ((data[0] >> 4) & 7) == 1
if frame < 80:
active[0] += int(is_active)
elif frame < 130:
neutral_seen |= not is_active
else:
active[1] += int(is_active)
assert forwarded > 2
assert neutral_seen
assert active[0] > 3 and active[1] > 10, active
assert safety.aol_get_permission_mask() == 1
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_unrelated_native_rejection_does_not_reset_granted_angle(ford_joined, monkeypatch):
from types import SimpleNamespace
from opendbc.car import structs
from opendbc.car.ford import fordcan
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
joined = ford_joined
native, safety = joined.native, joined.safety
native.arm()
adapter = joined.host.CI.CC.bp_lat
inputs = adapter.host.inputs
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
control = structs.CarControl(enabled=True, latActive=True)
control.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
source = SimpleNamespace(out=state, lat_ctl_lim_stat=0)
blocked = active_after = 0
rejected = False
for frame in range(180):
native.pump(1, mask=1, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
if frame == 80:
# LONG is independently denied. Its real rejection must not erase LAT.
message = fordcan.create_acc_msg(native.packer, joined.host.CI.CC.CAN, True, 1., 1., False, False, v_ego_kph=50.)
address, data, bus = message
assert not safety.get_controls_allowed()
assert not safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
assert safety.aol_get_permission_mask() == 1
adapter.observe_can([(now, [(address, data, bus | 0xC0)])], bus)
blocked += 1
rejected = True
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now)
panda.pandaStates[0].safetyTxBlocked = blocked
inputs.sm.update_msgs(now / 1e9, [panda.as_reader()])
packets = adapter.step_lateral(control, source, native.packer, joined.host.CI.CC.CAN, frame, now)
packets += adapter.step_lka(native.packer, joined.host.CI.CC.CAN, now)
for address, data, bus in packets:
assert safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data)), (frame, hex(address), data.hex())
if address in (0x3D3, 0x3D6) and frame >= 80:
active = ((data[4] >> 2) & 7) if address == 0x3D3 else ((data[0] >> 4) & 7)
assert active == 1, 'Unrelated LONG rejection reset independently granted steering'
active_after += 1
assert rejected and active_after > 10