Files
StarPilot/openpilot/starpilot/aol/tests/test_gm_sdgm_source_wait.py
T
2026-10-24 23:48:28 -05:00

468 lines
24 KiB
Python

"""Measured Card publication lapse through current factory/parser/SD/Controls.
Native RX earns controls; the extracted current pandad C++ writer requests the
actual host decision. No controls_allowed or native permission flag is forced.
Board heartbeat telemetry is an explicit transport fixture, not an execution of
the firmware heartbeat timer; its reported-loss case must veto retention.
This measures a current caller contract, not an unlogged original blocked call.
"""
from collections import deque
from contextlib import ExitStack
import os
import json
from pathlib import Path
from unittest import mock
import pytest
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.gm import gmcan
from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params
from opendbc.car.gm.values import CruiseButtons, DBC
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car import card as card_module
from openpilot.selfdrive.selfdrived.events import EventName
from openpilot.starpilot.aol.tests import test_gm as gm_fixture
from openpilot.starpilot.aol.tests import test_ioniq_availability as caller_fixture
from openpilot.starpilot.aol.tests.test_gm_startup_main import parsed_packets
from openpilot.starpilot.aol.wire import PandaSlot, SafetyState, decode_intent, encode_safety
from openpilot.starpilot.car.gm.steering_companion import SteeringCompanion
from openpilot.starpilot.aol.tests.gm_gap_cpp_bridge import gm_host_heartbeat_bridge as gm_host_heartbeat_bridge
BASE = 10_000_000_000
LAST_SOURCE = BASE + 250_000_000
GAP_AGE = 36_415_981
SOURCE_ADVANCE = 73_592_025
NATIVE_BEFORE_SOURCE = 12_576_709
class Capture:
def __init__(self):
self.messages = {}
def send(self, name, event):
raw = event if isinstance(event, bytes) else event.to_bytes()
self.messages[name] = messaging.log_from_bytes(raw)
def joined_case(cleanup, bridge, *, c9=False, pedal='none', engage=False, cp=None):
saved = Params()
cleanup.callback(saved._finalizer)
for key in ('OpenpilotEnabledToggle', 'AlwaysOnLateral', 'AlphaLongitudinalEnabled'):
saved.put_bool(key, True, block=True)
default_sdgm = cp is None
cp = sdgm_params(alpha=True, brake_c9=c9) if cp is None else cp
card = gm_fixture.TestGmAol.card(cp, saved)
cleanup.callback(card.vehicle_startup.close)
assert card.CP.flags == cp.flags and card.CP.alternativeExperience == 32
assert card.CP.safetyConfigs[0].safetyParam == cp.safetyConfigs[0].safetyParam
assert card.CP.pcmCruise == cp.pcmCruise and card.CP.openpilotLongitudinalControl == cp.openpilotLongitudinalControl
if default_sdgm and not c9:
assert card.CP.flags == 16 and card.CP.safetyConfigs[0].safetyParam == 0x5007
card.ci_initialized = True # No ECU/device I/O; native permission is earned below.
card.pm = Capture()
case = caller_fixture.IoniqAvailabilityTests(methodName='runTest')
cleanup.callback(case.doCleanups)
producer_message = messaging.new_message # Preserve Card's own publication clock before the SD fixture patches it.
sd, controls, step = case._authority_callers(False, cp=card.CP.as_reader().as_builder(), genuine_events=True)
# Restore the real constructor's settings snapshot removed by this generic fixture.
from openpilot.starpilot.aol.intent import read_settings
sd.aol_settings = read_settings(sd.params)
assert sd.aol_settings.enabled and sd.aol_settings.pause_brake_mps == 0.
cleanup.callback(sd.params._finalizer)
cleanup.callback(controls.params._finalizer)
native = libsafety_py.libsafety
# GM snapshots AE during mode initialization; configure it before selecting hooks.
native.set_alternative_experience(card.CP.alternativeExperience)
assert native.set_safety_hooks(card.CP.safetyConfigs[0].safetyModel.raw,
card.CP.safetyConfigs[0].safetyParam) == 0
# Match the shared native harness: hook selection resets the relay transition
# counter; initialize its established-mode fixture afterwards, then set AE.
native.init_tests()
native.set_alternative_experience(card.CP.alternativeExperience)
assert not native.get_controls_allowed()
bridge.reset_host()
packer = CANPacker(DBC[card.CP.carFingerprint][Bus.pt])
receipt_queue = deque()
sd.steering_companion = SteeringCompanion(sock=object())
original_receive = messaging.recv_one_or_none
original_update = sd.sm.update_msgs
original_controls_update = controls.sm.update_msgs
card_delivered = {}
controls_delivered = {}
delivered = {}
current = {'gap': False, 'raw_ahead': False, 'pedal': 'none', 'warmup_trace': [], 'last_cs': None, 'wire': None, 'pandas': None, 'last_command': None}
def receive(sock):
if sock is sd.steering_companion.sock:
return receipt_queue.popleft() if receipt_queue else None
return original_receive(sock)
def update(now, rows):
if current['gap'] and not current['raw_ahead']:
rows = [row for row in rows if row.which() not in ('aolIntentWire', 'carOutput', 'starpilotCarState')]
actual = []
for row in rows:
if row.which() in ('aolSafetyWire', 'pandaStates'):
signature = row.as_builder().to_bytes()
if delivered.get(row.which()) == signature:
continue
delivered[row.which()] = signature
actual.append(row)
return original_update(now, actual)
def controls_update(now, rows):
actual = []
for row in rows:
if current['gap'] and row.which() in ('carState', 'carOutput', 'aolIntentWire', 'starpilotCarState'):
continue
if row.which() in ('aolSafetyWire', 'pandaStates'):
signature = row.as_builder().to_bytes()
if controls_delivered.get(row.which()) == signature:
continue
controls_delivered[row.which()] = signature
actual.append(row)
return original_controls_update(now, actual)
cleanup.enter_context(mock.patch.object(messaging, 'recv_one_or_none', side_effect=receive))
cleanup.enter_context(mock.patch.object(controls.sm, 'update_msgs', side_effect=controls_update))
cleanup.enter_context(mock.patch.object(sd.sm, 'update_msgs', side_effect=update))
def physical(now, tick, *, main=True, button=CruiseButtons.UNPRESS, eps=1):
rows = parsed_packets(card.CP, packer, now, tick, 20., main)
if current['pedal'] != 'none':
from opendbc.car.gm.tests.test_volt_camera_control import feed_camera
from types import SimpleNamespace
_, extra = feed_camera(SimpleNamespace(update=lambda _: None), packer, now,
counter=tick % 4, speed=20., brake=current['pedal'] == 'brake',
regen=current['pedal'] == 'regen', active=False, eps_status=eps)
addresses = {0xBE, 0xC9, 0xF1, 0xBD}
rows = [row for row in rows if row[0] not in addresses]
rows += [row for row in extra if row[0] in addresses and row[0] != 0xBE]
rows.append(packer.make_can_msg('ECMAcceleratorPos', 0,
{'BrakePedalPos': 10 if current['pedal'] == 'brake' else 0}))
rows = [row for row in rows if row[0] not in (0x1E1, 0x184)]
rows += [gmcan.create_buttons(packer, 0, tick % 4, button),
packer.make_can_msg('PSCMStatus', 0, {'LKATorqueDeliveredStatus': eps})]
native.set_timer(now // 1000)
for address, data, bus in rows:
assert native.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data)), (hex(address), bus)
native.safety_tick()
return rows
def sample_native(now, *, heartbeat_lost=False):
axis = sd.pm.messages.get('aolAxisState')
intent = card.pm.messages.get('aolIntentWire')
axis_bytes = axis.as_builder().to_bytes() if axis is not None else b''
intent_bytes = intent.as_builder().to_bytes() if intent is not None else b''
native.set_timer(now // 1000)
plan = bridge.plan_host(4, card.CP.safetyConfigs[0].safetyParam,
native.aol_get_request_mask(), native.aol_get_permission_mask(), now,
axis_bytes, len(axis_bytes), intent_bytes, len(intent_bytes), sd.enabled)
native.aol_set_host_request(plan & 0xFF)
native.set_aol_test_heartbeat(bool(plan & 0x100))
bridge.complete_host(native.aol_get_request_mask(), native.aol_get_permission_mask())
mask, request = native.aol_get_permission_mask(), native.aol_get_request_mask()
bad_rx = not native.safety_config_valid()
relay = bool(native.get_relay_malfunction())
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now)
row = panda.pandaStates[0]
row.safetyModel, row.safetyParam = card.CP.safetyConfigs[0].safetyModel, card.CP.safetyConfigs[0].safetyParam
row.alternativeExperience, row.controlsAllowed = 32, native.get_controls_allowed()
row.safetyRxChecksInvalid = bad_rx
row.heartbeatLost = heartbeat_lost # Received board-health fixture; safety DLL has no firmware timer.
if relay:
row.faults = ['relayMalfunction']
slot = PandaSlot(0, 'gm-gap-native', 4, row.safetyParam, 32, row.controlsAllowed, bad_rx, heartbeat_lost, int(relay))
wire = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=now)
wire.aolSafetyWire = encode_safety(SafetyState(1, bool(plan & 0x400), now, now + 200_000_000,
4, row.safetyParam, bool(mask & 1), bool(mask & 2), bool(request & 1), bool(request & 2),
'gm-gap-native', sd.aol_session_id, now, (slot,)))
current['wire'], current['pandas'] = wire, panda
return mask
def card_receive(now, rows, *, publish=True):
peers = [current['wire'].as_reader(), current['pandas'].as_reader()]
peers += [sd.pm.messages[name] for name in ('aolAxisState', 'onroadEvents', 'selfdriveState') if name in sd.pm.messages]
if current['last_command'] is not None:
peers.append(current['last_command'])
device = messaging.new_message('deviceState', valid=sd.sm.valid['deviceState'],
logMonoTime=sd.sm.logMonoTime['deviceState'])
device.deviceState = sd.sm['deviceState']
peers.append(device.as_reader())
fresh = []
for row in peers:
signature = row.as_builder().to_bytes()
if card_delivered.get(row.which()) != signature:
card_delivered[row.which()] = signature
fresh.append(row)
card.sm.update_msgs(now / 1e9, fresh)
def card_message(*args, **kwargs):
kwargs.setdefault('logMonoTime', now)
return producer_message(*args, **kwargs)
with mock.patch.object(card_module.time, 'monotonic_ns', return_value=now), \
mock.patch.object(card_module.time, 'monotonic', return_value=now / 1e9), \
mock.patch.object(card_module.time, 'clock_gettime_ns', return_value=now), \
mock.patch.object(card_module.messaging, 'new_message', side_effect=card_message), \
mock.patch.object(card_module.messaging, 'drain_sock_raw', return_value=[b'physical-can']), \
mock.patch.object(card_module, 'can_capnp_to_list', return_value=[(now, rows)]), \
mock.patch.object(card_module, 'REPLAY', False), mock.patch.object(card.sm, 'update'), \
mock.patch.object(card.RI, 'update', return_value=None):
cs, rd = card.state_update()
if publish:
card.state_publish(cs, rd)
current['last_cs'] = cs
return cs
def drive(now, tick, *, gap=False, missing_companion=False):
current['gap'] = gap
assert current['wire'] is not None and current['pandas'] is not None
if (not gap or current['raw_ahead']) and not missing_companion:
receipt_queue.append(card.pm.messages['starpilotCarState'])
source = card.pm.messages['carState'].as_builder()
intent = card.pm.messages['aolIntentWire'].as_builder()
authority = None if (gap and not current['raw_ahead']) or missing_companion else card.pm.messages['starpilotCarState'].as_builder()
axis, state, command = step(tick, offset_ns=now - (BASE + tick * 10_000_000), timeout=gap,
car_state_message=None if gap else source, intent_message=intent, companion=False,
authority_message=authority, car_output_message=card.pm.messages['carOutput'].as_builder(),
safety_message=current['wire'], panda_state_message=current['pandas'])
current['last_command'] = controls.pm.messages['carControl']
return axis, state, command
def apply_command(now, command):
native.set_timer(now // 1000)
card.pm.messages.pop('sendcan', None)
# Card.step passes the decoded SubMaster reader, never Controls' builder.
# Match the same explicit BOOTTIME/monotonic clock used in card_receive.
received = current['last_command'].carControl
assert received.to_dict() == command.to_dict()
with mock.patch.object(card_module.time, 'monotonic_ns', return_value=now), \
mock.patch.object(card_module.time, 'monotonic', return_value=now / 1e9), \
mock.patch.object(card_module.time, 'clock_gettime_ns', return_value=now):
card.controls_update(current['last_cs'], received)
if 'sendcan' in card.pm.messages:
for frame in card.pm.messages['sendcan'].sendcan:
assert native.safety_tx_hook(libsafety_py.make_CANPacket(frame.address, frame.src, bytes(frame.dat)))
native_due = BASE
for tick in range(26):
now = BASE + tick * 10_000_000
if tick == 24:
sample_native(LAST_SOURCE - NATIVE_BEFORE_SOURCE)
native_due = LAST_SOURCE - NATIVE_BEFORE_SOURCE + 100_000_000
rows = physical(now, tick, main=not engage or tick >= 5,
button=CruiseButtons.DECEL_SET if engage and tick == 12 else CruiseButtons.UNPRESS)
if current['wire'] is None or now >= native_due:
sample_native(now)
native_due = now + 100_000_000
card_receive(now, rows)
axis, state, command = drive(now + 5_467_396, tick)
apply_command(now + 5_467_397, command)
current['warmup_trace'].append((tick, current['last_cs'].cruiseState.available,
card.aol_card_intent.allowed_latch, card.aol_card_intent._main_cycle_required,
card.aol_transport_feedback.failed, str(axis.faultReason), axis.lateralActive,
axis.longitudinalActive, native.aol_get_request_mask(), native.aol_get_permission_mask()))
if os.environ.get('GM_SDGM_TRACE'):
with Path(os.environ['GM_SDGM_TRACE']).open('a') as out:
out.write(json.dumps({'c9': c9, 'engage': engage, 'warmup': current['warmup_trace']}) + '\n')
assert native.get_controls_allowed() == engage and native.aol_get_permission_mask() == (3 if engage else 1)
assert axis.lateralActive and command.latActive
assert axis.longitudinalActive == engage and command.longActive == engage
assert state.enabled == engage and card.aol_card_intent.allowed_latch, current['warmup_trace']
assert not card.CI.CS.steering_authority.latched
assert decode_intent(card.pm.messages['aolIntentWire'].aolIntentWire).carStateLogMonoTime == LAST_SOURCE
return card, sd, controls, native, current, physical, sample_native, card_receive, drive, apply_command
@pytest.mark.parametrize('c9', (False, True))
@pytest.mark.parametrize('pedal', ('brake', 'regen'))
@pytest.mark.parametrize('fault', ('none', 'eps_loss', 'heartbeat', 'main_off'))
def test_sdgm_main_and_held_pedal_receive_order(gm_host_heartbeat_bridge, c9, pedal, fault):
with OpenpilotPrefix(), ExitStack() as cleanup, mock.patch.dict(os.environ, {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'}):
joined = joined_case(cleanup, gm_host_heartbeat_bridge, c9=c9, pedal=pedal, engage=True)
card, sd, controls, native, current, physical, sample_native, card_receive, drive, apply_command = joined
# Isolate the later brake gap from the independently reproduced cold MAIN
# bug: a physical MAIN cycle after host readiness and ordinary SET earn
# real native LONG first, then the pedal withdraws it.
# The separate cold-start/Long test below uses MAIN already ON without SET.
assert card.aol_card_intent.allowed_latch and sd.enabled and sd.active
current['pedal'] = pedal
for tick in range(26, 35):
now = BASE + tick * 10_000_000
rows = physical(now, tick)
if tick in (26, 30):
sample_native(now)
card_receive(now, rows)
axis, state, command = drive(now + 1, tick)
assert axis.lateralActive and command.latActive and not command.longActive
assert not state.enabled and card.aol_card_intent.allowed_latch
assert current['last_cs'].brakePressed == (pedal == 'brake')
assert card.pm.messages['carState'].carState.brakePressed == current['last_cs'].brakePressed
assert current['last_cs'].regenBraking == (pedal == 'regen')
assert card.pm.messages['carState'].carState.regenBraking == current['last_cs'].regenBraking
apply_command(now + 2, command)
source = BASE + 340_000_000
gap_time = source + 35_320_469
# Measured relative ages: original selected source35.320469ms;
# new Card source/raw5.224167ms ahead of the selected receiver sample.
newer = gap_time - 5_224_167
# Earn a real EPS fault through confirmed active feedback before returning
# to the held pedal. A neutral EPS response during a physical pedal pause
# is deliberately accepted by the unchanged vehicle monitor.
if fault == 'eps_loss':
current['pedal'] = 'none'
for tick in (35, 36):
stamp = BASE + tick * 10_000_000
rows = physical(stamp, tick, eps=0 if fault == 'eps_loss' and tick == 36 else 1)
if fault == 'eps_loss':
card_receive(stamp, rows, publish=False)
if tick == 35:
assert card.CI.CS.steering_authority.seen_active
else:
assert card.CI.CS.steering_authority.latched
if fault == 'eps_loss':
current['pedal'] = pedal
if fault == 'heartbeat':
sample_native(gap_time - 8_811_250, heartbeat_lost=True)
else:
sample_native(gap_time - 8_811_250)
rows = physical(newer, 37, main=fault != 'main_off', eps=0 if fault == 'eps_loss' else 1)
card_receive(newer, rows)
if fault == 'eps_loss':
assert card.CI.CS.steering_authority.latched
assert card.pm.messages['starpilotCarState'].starpilotCarState.lateralAuthorityUnavailable
current['raw_ahead'] = True
axis, state, command = drive(gap_time, 38, gap=True)
assert not command.latActive and not command.longActive
assert not axis.lateralActive and not axis.longitudinalActive
if fault != 'none':
if fault != 'main_off':
assert EventName.controlsMismatch in sd.events.names
return
assert not sd.steering_authority_unavailable and not sd.aol_authority_lost
assert EventName.controlsMismatch not in sd.events.names
assert str(axis.faultReason) == 'availabilityPause' and axis.sourceCarStateMonoTime == 0
# Fresh matching input must recover independently, with LONG still OFF.
fresh = gap_time + 10_689_334
rows = physical(fresh, 39)
card_receive(fresh, rows)
assert card.aol_card_intent.allowed_latch
recovered, state, command = drive(fresh + 1, 39)
assert recovered.lateralActive and command.latActive and not command.longActive
assert not state.enabled and not sd.aol_authority_lost
apply_command(fresh + 2, command)
assert sample_native(fresh + 10_000_000) == 1
@pytest.mark.parametrize('c9', (False, True))
@pytest.mark.parametrize('pedal', ('brake', 'regen'))
def test_sdgm_long_brake_withdrawal_keeps_main_lateral(gm_host_heartbeat_bridge, c9, pedal):
with OpenpilotPrefix(), ExitStack() as cleanup, mock.patch.dict(os.environ, {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'}):
joined = joined_case(cleanup, gm_host_heartbeat_bridge, c9=c9)
card, sd, controls, native, current, physical, sample_native, card_receive, drive, apply_command = joined
engaged = False
for tick in range(26, 47):
now = BASE + tick * 10_000_000
if tick == 38:
current['pedal'] = pedal
rows = physical(now, tick, button=CruiseButtons.DECEL_SET if tick == 27 else CruiseButtons.UNPRESS)
if tick in (26, 30, 37, 39, 44):
sample_native(now)
card_receive(now, rows)
axis, state, command = drive(now + 1, tick)
if 32 <= tick < 38:
assert state.enabled and axis.longitudinalActive and command.longActive
engaged = True
if tick >= 38:
assert not state.enabled and not command.longActive and not axis.longitudinalActive
assert axis.lateralActive and command.latActive and card.aol_card_intent.allowed_latch
assert not sd.aol_authority_lost and EventName.controlsMismatch not in sd.events.names
apply_command(now + 2, command)
assert engaged
@pytest.mark.parametrize('c9', (False, True))
@pytest.mark.parametrize('pedal', ('brake', 'regen'))
@pytest.mark.parametrize('status', (2, 3))
def test_sdgm_factory_eps_withdrawal_and_recovery(gm_host_heartbeat_bridge, c9, pedal, status):
"""Factory EPS faults withdraw output; healthy MAIN recovers independent LAT."""
from openpilot.starpilot.aol.wire import decode_safety
with OpenpilotPrefix(), ExitStack() as cleanup, mock.patch.dict(os.environ, {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'}):
joined = joined_case(cleanup, gm_host_heartbeat_bridge, c9=c9, engage=True)
card, sd, controls, native, current, physical, sample_native, card_receive, drive, apply_command = joined
current['pedal'] = pedal
def cycle(index, *, eps=1, main=True):
now = BASE + index * 10_000_000
rows = physical(now, index, main=main, eps=eps)
card_receive(now, rows)
axis, state, command = drive(now + 1, index)
apply_command(now + 2, command)
mask = sample_native(now + 3)
snapshot = decode_safety(bytes(current['wire'].aolSafetyWire))
assert snapshot is not None and snapshot.compatible
assert snapshot.observedMonoTime == snapshot.sourcePandaStatesMonoTime == now + 3
assert snapshot.safetyParam == card.CP.safetyConfigs[0].safetyParam
assert not command.longActive and not axis.longitudinalActive and not state.enabled
return axis, command, mask, snapshot
# Withdraw an actually engaged LONG with the physical pedal, retaining LAT.
for index in range(26, 31):
axis, command, mask, _ = cycle(index)
assert axis.lateralActive and command.latActive and mask == 1
assert card.aol_card_intent.allowed_latch
assert not native.get_controls_allowed()
packer = CANPacker(DBC[card.CP.carFingerprint][Bus.pt])
address, data, bus = gmcan.create_steering_control(packer, 0, 1, 0, True)
def nonzero_native_request():
return bool(native.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data)))
assert nonzero_native_request() # Establish this exact valid packet's granted baseline.
for index in range(31, 35):
axis, command, mask, snapshot = cycle(index, eps=status)
parsed = current['last_cs']
assert parsed.steerFaultTemporary == (status == 2)
assert parsed.steerFaultPermanent == (status == 3)
assert not axis.lateralActive and not command.latActive
assert not card.last_actuators_output.torqueOutputCan
# Actual host OFF reaches the current C++ owner after this physical RX;
# GM firmware reads driver torque from PSCM, not its factory fault enum.
assert mask == native.aol_get_request_mask() == native.aol_get_permission_mask() == 0
assert not snapshot.requestedLateral and not snapshot.lateralAllowed
assert not snapshot.requestedLongitudinal and not snapshot.longitudinalAllowed
assert not nonzero_native_request()
if status == 2:
assert card.aol_card_intent.allowed_latch
assert EventName.controlsMismatch not in sd.events.names
else:
assert not card.aol_card_intent.allowed_latch
# Release the pedal and clear the fault. Neither is a new LONG engagement.
current['pedal'] = 'none'
recovered = False
for index in range(35, 40):
axis, command, mask, _ = cycle(index)
assert not current['last_cs'].steerFaultTemporary and not current['last_cs'].steerFaultPermanent
if not card.aol_card_intent.allowed_latch:
assert not axis.lateralActive and not command.latActive and mask == 0
recovered |= bool(card.aol_card_intent.allowed_latch and axis.lateralActive and command.latActive and mask == 1)
# Fresh healthy EPS and native acknowledgment recover persistent MAIN intent.
# Neither an active fault nor a missing native grant was accepted above.
assert recovered and nonzero_native_request()
for index in range(40, 44):
axis, command, mask, _ = cycle(index, main=False)
assert not card.aol_card_intent.allowed_latch and mask == 0
assert not axis.lateralActive and not command.latActive
recovered = False
for index in range(44, 49):
axis, command, mask, _ = cycle(index)
recovered |= bool(axis.lateralActive and command.latActive and mask == 1)
assert recovered and card.aol_card_intent.allowed_latch
assert nonzero_native_request()