Files
StarPilot/openpilot/common/hardware/comma/tests/test_c3_startup.py
T
firestar5683 80fbb4f6db Feature registration
Register vehicle controls, planner modes, UI actions and Galaxy services after their providers exist. Wire the shared runtime entrypoints and cover their combined behavior.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Kacper Rączy <gfw.kra@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com>
Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

192 lines
11 KiB
Python

import ast
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, call
import pytest
SOURCE = Path(__file__).resolve().parents[1] / "hardware.py"
@pytest.mark.parametrize("device,usb", [("tici", True), ("tizi", False), ("mici", False)])
def test_internal_panda_transport_irq_affinity(device, usb):
tree = ast.parse(SOURCE.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "HardwareComma")
method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == "initialize_hardware")
start = next(i for i, node in enumerate(method.body) if isinstance(node, ast.Expr) and
ast.unparse(node.value) == "affine_irq(3, 'spi_geni')")
affinity = Mock()
code = compile(ast.fix_missing_locations(ast.Module(body=method.body[start:start + 2], type_ignores=[])), str(SOURCE), "exec")
exec(code, {"self": SimpleNamespace(get_device_type=lambda: device), "affine_irq": affinity})
expected = [(3, "spi_geni")]
if usb:
expected += [(3, "xhci-hcd:usb3"), (3, "xhci-hcd:usb1")]
assert [call.args for call in affinity.call_args_list] == expected
@pytest.mark.parametrize('model,right_volume,eq_enabled', [('tici', 0x1c, 1), ('tizi', 0x17, 0)])
def test_amplifier_profile_registers_and_shutdown_order(model, right_volume, eq_enabled):
from collections import namedtuple
path = SOURCE.with_name('amplifier.py')
tree = ast.parse(path.read_text())
selected: list[ast.stmt] = [node for node in tree.body if isinstance(node, (ast.Assign, ast.FunctionDef, ast.ClassDef))]
namespace: dict = {'namedtuple': namedtuple}
exec(compile(ast.fix_missing_locations(ast.Module(body=selected, type_ignores=[])), str(path), 'exec'), namespace)
amp = namespace['Amplifier'].__new__(namespace['Amplifier'])
amp.set_configs = Mock(return_value=True)
assert amp.initialize_configuration(model)
configs = amp.set_configs.call_args.args[0]
assert configs[0].register == configs[-1].register == 0x51
assert configs[0].value == 0 and configs[-1].value == 1
assert next(config.value for config in configs if config.register == 0x3e) == right_volume
eq = [config.value for config in configs if config.register == 0x49 and config.offset == 1]
assert eq[-1] == eq_enabled
assert sum(config.register >= 0x84 for config in configs) == (50 if model == 'tici' else 0)
def test_hardware_startup_and_wake_pass_actual_amplifier_model():
tree = ast.parse(SOURCE.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'HardwareComma')
for name in ('initialize_hardware', 'set_power_save'):
method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == name)
calls = [node for node in ast.walk(method) if isinstance(node, ast.Call) and
ast.unparse(node.func) == 'self.amplifier.initialize_configuration']
assert len(calls) == 1
assert ast.unparse(calls[0].args[0]) == 'self.get_device_type()'
@pytest.mark.parametrize('model', ['tici', 'tizi'])
@pytest.mark.parametrize('path_name', ['initialize_hardware', 'set_power_save'])
def test_startup_and_wake_write_model_registers(model, path_name):
from collections import namedtuple
registers = dict.fromkeys(range(256), 0xa5)
writes = []
class Bus:
def __init__(self, bus):
assert bus == 0
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read_byte_data(self, address, register, force):
assert address == 0x10 and force
return registers[register]
def write_byte_data(self, address, register, value, force):
assert address == 0x10 and force
registers[register] = value
writes.append((register, value))
amp_path = SOURCE.with_name('amplifier.py')
tree = ast.parse(amp_path.read_text())
selected: list[ast.stmt] = [node for node in tree.body if isinstance(node, (ast.Assign, ast.FunctionDef, ast.ClassDef))]
namespace: dict = {'namedtuple': namedtuple, 'SMBus': Bus}
exec(compile(ast.fix_missing_locations(ast.Module(body=selected, type_ignores=[])), str(amp_path), 'exec'), namespace)
amp = namespace['Amplifier']()
tree = ast.parse(SOURCE.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'HardwareComma')
method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == path_name)
code = compile(ast.fix_missing_locations(ast.Module(body=[method.body[0]], type_ignores=[])), str(SOURCE), 'exec')
exec(code, {'self': SimpleNamespace(amplifier=amp, get_device_type=lambda: model), 'powersave_enabled': False})
expected_registers = dict.fromkeys(range(256), 0xa5)
configs = [amp._get_shutdown_config(True), *namespace['BASE_CONFIG'], *namespace['CONFIGS'][model], amp._get_shutdown_config(False)]
if path_name == 'set_power_save':
configs.insert(0, amp._get_shutdown_config(False))
expected = []
for config in configs:
value = (expected_registers[config.register] & ~config.mask) | ((config.value << config.offset) & config.mask)
expected_registers[config.register] = value
expected.append((config.register, value))
assert writes == expected
assert registers == expected_registers
assert registers[0x3e] & 0x1f == (0x1c if model == 'tici' else 0x17)
assert registers[0x49] & 2 == (2 if model == 'tici' else 0)
assert len([register for register, _ in writes if register >= 0x84]) == (50 if model == 'tici' else 0)
@pytest.mark.parametrize('device,reset_hold,recover_hold', [('tici', 1, 0.5), ('tizi', 0.01, 0.01), ('mici', 0.01, 0.01)])
def test_internal_panda_reset_and_recovery_timing(device, reset_hold, recover_hold):
tree = ast.parse(SOURCE.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'HardwareComma')
methods: list[ast.stmt] = [node for node in cls.body if isinstance(node, ast.FunctionDef) and
node.name in {'reset_internal_panda', 'recover_internal_panda'}]
events = Mock()
namespace: dict = {'gpio_init': Mock(), 'gpio_set': events.gpio, 'time': SimpleNamespace(sleep=events.sleep),
'GPIO': SimpleNamespace(STM_RST_N='reset', STM_BOOT0='boot0')}
exec(compile(ast.Module(body=methods, type_ignores=[]), str(SOURCE), 'exec'), namespace)
hardware = SimpleNamespace(get_device_type=lambda: device)
namespace['reset_internal_panda'](hardware)
assert events.mock_calls == [call.gpio('reset', True), call.gpio('boot0', False), call.sleep(reset_hold), call.gpio('reset', False)]
events.reset_mock()
namespace['recover_internal_panda'](hardware)
assert events.mock_calls == [call.gpio('reset', True), call.gpio('boot0', True), call.sleep(recover_hold),
call.gpio('reset', False), call.sleep(recover_hold), call.gpio('boot0', False)]
@pytest.mark.parametrize('device', ['tici', 'tizi', 'mici'])
@pytest.mark.parametrize('failure', [None, 'missing', 'usb', 'protocol', 'multiple', 'dfu', 'wait', 'initial_wait', 'heartbeat'])
def test_pandad_enumeration_and_recovery(device, failure):
pandad_path = SOURCE.parents[3] / 'selfdrive/pandad/pandad.py'
tree = ast.parse(pandad_path.read_text())
functions: list[ast.stmt] = [node for node in tree.body if isinstance(node, ast.FunctionDef) and
node.name in {'wait_for_internal_panda', 'main'}]
events = Mock()
first_discovery = {'missing': [], 'usb': OSError(), 'multiple': ['one', 'two'], 'wait': []}.get(failure, ['serial'])
initial_ready = device != 'tici' or failure not in ('wait', 'initial_wait')
initial_discovery = ['serial'] if failure == 'heartbeat' else []
events.list.side_effect = [*([initial_discovery] if initial_ready else []), first_discovery,
*([] if failure == 'multiple' else [['serial']]), KeyboardInterrupt()]
events.health.return_value = {'heartbeat_lost': True}
panda = MagicMock(list=events.list)
panda.return_value.__enter__.return_value = SimpleNamespace(health=events.health, is_internal=lambda: True)
events.dfu_list.return_value = ['dfu'] if failure == 'dfu' else []
events.udev.return_value = SimpleNamespace(returncode=1 if failure == 'wait' else 0)
if failure == 'initial_wait':
events.udev.side_effect = [SimpleNamespace(returncode=1), SimpleNamespace(returncode=0)]
dfu = Mock(return_value=SimpleNamespace(recover=events.dfu_recover), list=events.dfu_list)
flash = Mock(side_effect=[RuntimeError(), None] if failure == 'protocol' else None)
process = Mock()
process.wait.side_effect = KeyboardInterrupt()
launch = Mock(return_value=process)
namespace: dict = {'Panda': panda, 'PandaDFU': dfu,
'HARDWARE': SimpleNamespace(get_device_type=lambda: device, reset_internal_panda=events.reset,
recover_internal_panda=events.recover),
'time': SimpleNamespace(sleep=events.sleep), 'cloudlog': Mock(), 'signal': Mock(),
'usb1': SimpleNamespace(USBErrorNoDevice=OSError, USBErrorPipe=BrokenPipeError), 'PandaProtocolMismatch': RuntimeError,
'Params': Mock(return_value=SimpleNamespace(put_bool=events.put_bool)),
'flash_panda': flash, 'subprocess': SimpleNamespace(Popen=launch, run=events.udev, DEVNULL=subprocess.DEVNULL), 'BASEDIR': 'base',
'os': SimpleNamespace(environ={}, path=SimpleNamespace(join=lambda *parts: '/'.join(parts)))}
exec(compile(ast.Module(body=functions, type_ignores=[]), str(pandad_path), 'exec'), namespace)
with pytest.raises(KeyboardInterrupt):
namespace['main']()
wait = [call.udev(['udevadm', 'wait', '--timeout=15', '/sys/bus/usb/devices/1-1.2'], check=False, stderr=None)] if device == 'tici' else []
expected = [call.udev(['udevadm', 'wait', '--timeout=0', '/sys/bus/usb/devices/1-1.2'], check=False,
stderr=subprocess.DEVNULL)] if device == 'tici' else []
if initial_ready:
expected += [call.list()] # Initial health inspection.
if failure == 'heartbeat':
expected += [call.health(), call.put_bool('PandaHeartbeatLost', True, block=True)]
for reset in [call.reset()] if failure in (None, 'dfu', 'initial_wait', 'heartbeat') else [call.reset(), call.recover()]:
expected += [reset, *wait, call.dfu_list()]
if failure == 'dfu':
expected += [call.dfu_recover(), call.sleep(1), *wait]
expected += [call.list()]
assert events.mock_calls == expected
if failure == 'dfu':
dfu.assert_called_once_with('dfu')
if failure == 'multiple':
flash.assert_not_called()
launch.assert_not_called()
else:
assert flash.call_args_list == [call('serial')] * (2 if failure == 'protocol' else 1)
launch.assert_called_once_with(['./pandad', 'serial'], cwd='base/openpilot/selfdrive/pandad')