IQ.Pilot Prebuilt Release @ 27f668a

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-03 18:23:24 -05:00
commit b073c5182b
2554 changed files with 679696 additions and 0 deletions

3
iqpilot/selfdrive/pandad/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
pandad
pandad_api_impl.cpp
tests/test_pandad_usbprotocol

View File

@@ -0,0 +1,3 @@
from iqpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list
assert can_list_to_can_capnp
assert can_capnp_to_list

BIN
iqpilot/selfdrive/pandad/pandad Executable file

Binary file not shown.

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
# simple pandad wrapper that updates the panda first
import os
import usb1
import time
import signal
import subprocess
from panda import Panda, PandaDFU, PandaProtocolMismatch, FW_PATH
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.system.hardware import HARDWARE
from iqpilot.common.swaglog import cloudlog
def get_expected_signature(panda: Panda) -> bytes:
try:
fn = os.path.join(FW_PATH, panda.get_mcu_type().config.app_fn)
return Panda.get_signature_from_firmware(fn)
except Exception:
cloudlog.exception("Error computing expected signature")
return b""
def flash_panda(panda_serial: str) -> Panda:
try:
panda = Panda(panda_serial)
except PandaProtocolMismatch:
cloudlog.warning("detected protocol mismatch, reflashing panda")
HARDWARE.recover_internal_panda()
raise
# skip flashing if the detected panda is not supported
supported_panda = check_panda_support(panda)
if not supported_panda:
cloudlog.warning(f"Panda {panda_serial} is not supported (hw_type: {panda.get_type()}), skipping flash...")
return panda
fw_signature = get_expected_signature(panda)
internal_panda = panda.is_internal()
panda_version = "bootstub" if panda.bootstub else panda.get_version()
panda_signature = b"" if panda.bootstub else panda.get_signature()
cloudlog.warning(f"Panda {panda_serial} connected, version: {panda_version}, signature {panda_signature.hex()[:16]}, expected {fw_signature.hex()[:16]}")
if panda.bootstub or panda_signature != fw_signature:
cloudlog.info("Panda firmware out of date, update required")
panda.flash()
cloudlog.info("Done flashing")
if panda.bootstub:
bootstub_version = panda.get_version()
cloudlog.info(f"Flashed firmware not booting, flashing development bootloader. {bootstub_version=}, {internal_panda=}")
if internal_panda:
HARDWARE.recover_internal_panda()
panda.recover(reset=(not internal_panda))
cloudlog.info("Done flashing bootstub")
if panda.bootstub:
cloudlog.info("Panda still not booting, exiting")
raise AssertionError
panda_signature = panda.get_signature()
if panda_signature != fw_signature:
cloudlog.info("Version mismatch after flashing, exiting")
raise AssertionError
return panda
def check_panda_support(panda) -> bool:
hw_type = panda.get_type()
if hw_type in Panda.SUPPORTED_DEVICES:
return True
if hw_type == Panda.HW_TYPE_UNKNOWN and (os.environ.get('LITE') == '1' or os.path.exists('/tmp/lite_hw')):
return True
return False
def main() -> None:
os.environ["IQPILOT_PANDA_FW_PATH"] = FW_PATH
# signal pandad to close the relay and exit
def signal_handler(signum, frame):
cloudlog.info(f"Caught signal {signum}, exiting")
nonlocal do_exit
do_exit = True
if process is not None:
process.send_signal(signal.SIGINT)
process = None
do_exit = False
signal.signal(signal.SIGINT, signal_handler)
count = 0
first_run = True
params = Params()
no_internal_panda_count = 0
while not do_exit:
try:
count += 1
cloudlog.event("pandad.flash_and_connect", count=count)
params.remove("PandaSignatures")
# Handle missing internal panda
if time.monotonic() < 60.:
no_internal_panda_count = 0
if no_internal_panda_count > 0:
if no_internal_panda_count == 3:
cloudlog.info("No pandas found, putting internal panda into DFU")
HARDWARE.recover_internal_panda()
else:
cloudlog.info("No pandas found, resetting internal panda")
HARDWARE.reset_internal_panda()
time.sleep(3) # wait to come back up
# Flash all Pandas in DFU mode
dfu_serials = PandaDFU.list()
if len(dfu_serials) > 0:
for serial in dfu_serials:
cloudlog.info(f"Panda in DFU mode found, flashing recovery {serial}")
PandaDFU(serial).recover()
time.sleep(1)
panda_serials = Panda.list()
if len(panda_serials) == 0:
no_internal_panda_count += 1
continue
cloudlog.info(f"{len(panda_serials)} panda(s) found, connecting - {panda_serials}")
# Flash pandas
pandas: list[Panda] = []
for serial in panda_serials:
pandas.append(flash_panda(serial))
# Ensure internal panda is present if expected
internal_pandas = [panda for panda in pandas if panda.is_internal()]
if HARDWARE.has_internal_panda() and len(internal_pandas) == 0:
cloudlog.error("Internal panda is missing, trying again")
no_internal_panda_count += 1
continue
no_internal_panda_count = 0
# sort pandas to have deterministic order
# * the internal one is always first
# * then sort by hardware type
# * as a last resort, sort by serial number
pandas.sort(key=lambda x: (not x.is_internal(), x.get_type(), x.get_usb_serial()))
panda_serials = [p.get_usb_serial() for p in pandas]
# log panda fw versions
params.put("PandaSignatures", b','.join(p.get_signature() for p in pandas))
for panda in pandas:
# skip health check if the detected panda is not supported
supported_panda = check_panda_support(panda)
if not supported_panda:
cloudlog.warning(f"Panda {panda.get_usb_serial()} is not supported (hw_type: {panda.get_type()}), skipping health check...")
continue
# check health for lost heartbeat
health = panda.health()
if health["heartbeat_lost"]:
params.put_bool("PandaHeartbeatLost", True)
cloudlog.event("heartbeat lost", deviceState=health, serial=panda.get_usb_serial())
if health["som_reset_triggered"]:
params.put_bool("PandaSomResetTriggered", True)
cloudlog.event("panda.som_reset_triggered", health=health, serial=panda.get_usb_serial())
if first_run:
# reset panda to ensure we're in a good state
cloudlog.info(f"Resetting panda {panda.get_usb_serial()}")
panda.reset(reconnect=True)
for p in pandas:
p.close()
# TODO: wrap all panda exceptions in a base panda exception
except (usb1.USBErrorNoDevice, usb1.USBErrorPipe):
# a panda was disconnected while setting everything up. let's try again
cloudlog.exception("Panda USB exception while setting up")
continue
except PandaProtocolMismatch:
cloudlog.exception("pandad.protocol_mismatch")
continue
except Exception:
cloudlog.exception("pandad.uncaught_exception")
continue
first_run = False
# run pandad with all connected serials as arguments
os.environ['MANAGER_DAEMON'] = 'pandad'
process = subprocess.Popen(["./pandad", *panda_serials], cwd=os.path.join(BASEDIR, "iqpilot/selfdrive/pandad"))
process.wait()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
import time
from iqpilot.cereal import log
NO_TRAVERSAL_LIMIT = 2**64 - 1
# Cache schema fields for faster access (avoids string lookup on each field access)
_cached_reader_fields = None # (address_field, dat_field, src_field) for reading
_cached_writer_fields = None # (address_field, dat_field, src_field) for writing
def _get_reader_fields(schema):
"""Get cached schema field objects for reading."""
global _cached_reader_fields
if _cached_reader_fields is None:
fields = schema.fields
_cached_reader_fields = (fields['address'], fields['dat'], fields['src'])
return _cached_reader_fields
def _get_writer_fields(schema):
"""Get cached schema field objects for writing."""
global _cached_writer_fields
if _cached_writer_fields is None:
fields = schema.fields
_cached_writer_fields = (fields['address'], fields['dat'], fields['src'])
return _cached_writer_fields
def can_list_to_can_capnp(can_msgs, msgtype='can', valid=True):
"""Convert list of CAN messages to Cap'n Proto serialized bytes.
Args:
can_msgs: List of tuples [(address, data_bytes, src), ...]
msgtype: 'can' or 'sendcan'
valid: Whether the event is valid
Returns:
Cap'n Proto serialized bytes
"""
global _cached_writer_fields
dat = log.Event.new_message(valid=valid, logMonoTime=int(time.monotonic() * 1e9))
can_data = dat.init(msgtype, len(can_msgs))
# Cache schema fields on first call
if _cached_writer_fields is None and len(can_msgs) > 0:
_cached_writer_fields = _get_writer_fields(can_data[0].schema)
if _cached_writer_fields is not None:
addr_f, dat_f, src_f = _cached_writer_fields
for i, msg in enumerate(can_msgs):
f = can_data[i]
f._set_by_field(addr_f, msg[0])
f._set_by_field(dat_f, msg[1])
f._set_by_field(src_f, msg[2])
return dat.to_bytes()
def can_capnp_to_list(strings, msgtype='can'):
"""Convert Cap'n Proto serialized bytes to list of CAN messages.
Args:
strings: Tuple/list of serialized Cap'n Proto bytes
msgtype: 'can' or 'sendcan'
Returns:
List of tuples [(nanos, [(address, data, src), ...]), ...]
"""
global _cached_reader_fields
result = []
for s in strings:
with log.Event.from_bytes(s, traversal_limit_in_words=NO_TRAVERSAL_LIMIT) as event:
frames = getattr(event, msgtype)
# Cache schema fields on first frame for faster access
if _cached_reader_fields is None and len(frames) > 0:
_cached_reader_fields = _get_reader_fields(frames[0].schema)
if _cached_reader_fields is not None:
addr_f, dat_f, src_f = _cached_reader_fields
frame_list = [(f._get_by_field(addr_f), f._get_by_field(dat_f), f._get_by_field(src_f)) for f in frames]
else:
frame_list = []
result.append((event.logMonoTime, frame_list))
return result

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,114 @@
import os
import pytest
import time
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import log
from iqpilot.common.gpio import gpio_set, gpio_init
from panda import Panda, PandaDFU
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.hardware.tici.pins import GPIO
HERE = os.path.dirname(os.path.realpath(__file__))
@pytest.mark.tici
class TestPandad:
def setup_method(self):
# ensure panda is up
if len(Panda.list()) == 0:
self._run_test(60)
def teardown_method(self):
managed_processes['pandad'].stop()
def _run_test(self, timeout=30) -> float:
st = time.monotonic()
sm = messaging.SubMaster(['pandaStates'])
managed_processes['pandad'].start()
while (time.monotonic() - st) < timeout:
sm.update(100)
if len(sm['pandaStates']) and sm['pandaStates'][0].pandaType != log.PandaState.PandaType.unknown:
break
dt = time.monotonic() - st
managed_processes['pandad'].stop()
if len(sm['pandaStates']) == 0 or sm['pandaStates'][0].pandaType == log.PandaState.PandaType.unknown:
raise Exception("pandad failed to start")
return dt
def _go_to_dfu(self):
HARDWARE.recover_internal_panda()
assert Panda.wait_for_dfu(None, 10)
def _assert_no_panda(self):
assert not Panda.wait_for_dfu(None, 3)
assert not Panda.wait_for_panda(None, 3)
def _flash_bootstub(self, fn):
self._go_to_dfu()
pd = PandaDFU(None)
if fn is None:
fn = os.path.join(HERE, pd.get_mcu_type().config.bootstub_fn)
with open(fn, "rb") as f:
pd.program_bootstub(f.read())
pd.reset()
HARDWARE.reset_internal_panda()
def test_in_dfu(self):
HARDWARE.recover_internal_panda()
self._run_test(60)
def test_in_bootstub(self):
with Panda() as p:
p.reset(enter_bootstub=True)
assert p.bootstub
self._run_test()
def test_internal_panda_reset(self):
gpio_init(GPIO.STM_RST_N, True)
gpio_set(GPIO.STM_RST_N, 1)
time.sleep(0.5)
assert all(not Panda(s).is_internal() for s in Panda.list())
self._run_test()
assert any(Panda(s).is_internal() for s in Panda.list())
def test_best_case_startup_time(self):
# run once so we're up to date
self._run_test(60)
ts = []
for _ in range(10):
# should be nearly instant this time
dt = self._run_test(5)
ts.append(dt)
# 5s for USB (due to enumeration)
# - 0.2s pandad -> pandad
# - plus some buffer
print("startup times", ts, sum(ts) / len(ts))
assert 0.1 < (sum(ts)/len(ts)) < 0.7
def test_old_spi_protocol(self):
# flash firmware with old SPI protocol
self._flash_bootstub(os.path.join(HERE, "bootstub.panda_h7_spiv0.bin"))
self._run_test(45)
def test_release_to_devel_bootstub(self):
self._flash_bootstub(None)
self._run_test(45)
def test_recover_from_bad_bootstub(self):
self._go_to_dfu()
with PandaDFU(None) as pd:
pd.program_bootstub(b"\x00"*1024)
pd.reset()
HARDWARE.reset_internal_panda()
self._assert_no_panda()
self._run_test(60)

View File

@@ -0,0 +1,113 @@
import os
import copy
import random
import time
import pytest
from collections import defaultdict
from pprint import pprint
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import car, log
from iqdbc.car.can_definitions import CanData
from iqpilot.common.utils import retry
from iqpilot.common.params import Params
from iqpilot.common.timeout import Timeout
from iqpilot.selfdrive.pandad import can_list_to_can_capnp
from iqpilot.system.hardware import TICI
from iqpilot.selfdrive.test.helpers import with_processes
@retry(attempts=3)
def setup_pandad(num_pandas):
params = Params()
params.clear_all()
params.put_bool("IsOnroad", False)
sm = messaging.SubMaster(['pandaStates'])
with Timeout(90, "pandad didn't start"):
while sm.recv_frame['pandaStates'] < 1 or len(sm['pandaStates']) == 0 or \
any(ps.pandaType == log.PandaState.PandaType.unknown for ps in sm['pandaStates']):
sm.update(1000)
found_pandas = len(sm['pandaStates'])
assert num_pandas == found_pandas, "connected pandas ({found_pandas}) doesn't match expected panda count ({num_pandas}). \
connect another panda for multipanda tests."
# pandad safety setting relies on these params
cp = car.CarParams.new_message()
safety_config = car.CarParams.SafetyConfig.new_message()
safety_config.safetyModel = car.CarParams.SafetyModel.allOutput
cp.safetyConfigs = [safety_config]*num_pandas
params.put_bool("IsOnroad", True)
params.put_bool("FirmwareQueryDone", True)
params.put_bool("ControlsReady", True)
params.put("CarParams", cp.to_bytes())
with Timeout(90, "pandad didn't set safety mode"):
while any(ps.safetyModel != car.CarParams.SafetyModel.allOutput for ps in sm['pandaStates']):
sm.update(1000)
def send_random_can_messages(sendcan, count, num_pandas=1):
sent_msgs = defaultdict(set)
for _ in range(count):
to_send = []
for __ in range(random.randrange(20)):
bus = random.choice([b for b in range(3*num_pandas) if b % 4 != 3])
addr = random.randrange(1, 1<<29)
dat = bytes(random.getrandbits(8) for _ in range(random.randrange(1, 9)))
if (addr, dat) in sent_msgs[bus]:
continue
sent_msgs[bus].add((addr, dat))
to_send.append(CanData(addr, dat, bus))
sendcan.send(can_list_to_can_capnp(to_send, msgtype='sendcan'))
return sent_msgs
@pytest.mark.tici
class TestBoarddLoopback:
@classmethod
def setup_class(cls):
os.environ['STARTED'] = '1'
os.environ['BOARDD_LOOPBACK'] = '1'
@with_processes(['pandad'])
def test_loopback(self):
num_pandas = 2 if TICI and "SINGLE_PANDA" not in os.environ else 1
setup_pandad(num_pandas)
sendcan = messaging.pub_sock('sendcan')
can = messaging.sub_sock('can', conflate=False, timeout=100)
sm = messaging.SubMaster(['pandaStates'])
time.sleep(1)
n = 200
for i in range(n):
print(f"pandad loopback {i}/{n}")
sent_msgs = send_random_can_messages(sendcan, random.randrange(20, 100), num_pandas)
sent_loopback = copy.deepcopy(sent_msgs)
sent_loopback.update({k+128: copy.deepcopy(v) for k, v in sent_msgs.items()})
sent_total = {k: len(v) for k, v in sent_loopback.items()}
for _ in range(100 * 5):
sm.update(0)
recvd = messaging.drain_sock(can, wait_for_one=True)
for msg in recvd:
for m in msg.can:
key = (m.address, m.dat)
assert key in sent_loopback[m.src], f"got unexpected msg: {m.src=} {m.address=} {m.dat=}"
sent_loopback[m.src].discard(key)
if all(len(v) == 0 for v in sent_loopback.values()):
break
# if a set isn't empty, messages got dropped
pprint(sent_msgs)
pprint(sent_loopback)
print({k: len(x) for k, x in sent_loopback.items()})
print(sum([len(x) for x in sent_loopback.values()]))
pprint(sm['pandaStates']) # may drop messages due to RX buffer overflow
for bus in sent_loopback.keys():
assert not len(sent_loopback[bus]), f"loop {i}: bus {bus} missing {len(sent_loopback[bus])} out of {sent_total[bus]} messages"

View File

@@ -0,0 +1,102 @@
import os
import time
import numpy as np
import pytest
import random
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal.services import SERVICE_LIST
from iqpilot.selfdrive.test.helpers import with_processes
from iqpilot.selfdrive.pandad.tests.test_pandad_loopback import setup_pandad, send_random_can_messages
JUNGLE_SPAM = "JUNGLE_SPAM" in os.environ
@pytest.mark.tici
class TestBoarddSpi:
@classmethod
def setup_class(cls):
os.environ['STARTED'] = '1'
os.environ['SPI_ERR_PROB'] = '0.001'
if not JUNGLE_SPAM:
os.environ['BOARDD_LOOPBACK'] = '1'
@with_processes(['pandad'])
def test_spi_corruption(self, subtests):
setup_pandad(1)
sendcan = messaging.pub_sock('sendcan')
socks = {s: messaging.sub_sock(s, conflate=False, timeout=100) for s in ('can', 'pandaStates', 'peripheralState')}
time.sleep(2)
for s in socks.values():
messaging.drain_sock_raw(s)
total_recv_count = 0
total_sent_count = 0
sent_msgs = {bus: list() for bus in range(3)}
st = time.monotonic()
ts = {s: list() for s in socks.keys()}
for _ in range(int(os.getenv("TEST_TIME", "20"))):
# send some CAN messages
if not JUNGLE_SPAM:
sent = send_random_can_messages(sendcan, random.randrange(2, 20))
for k, v in sent.items():
sent_msgs[k].extend(list(v))
total_sent_count += len(v)
for service, sock in socks.items():
for m in messaging.drain_sock(sock):
ts[service].append(m.logMonoTime)
# sanity check for corruption
assert m.valid or (service == "can")
if service == "can":
for msg in m.can:
if JUNGLE_SPAM:
# PandaJungle.set_generated_can(True)
i = msg.address - 0x200
assert msg.address >= 0x200
assert msg.src == (i%3)
assert msg.dat == b"\xff"*(i%8)
total_recv_count += 1
continue
if msg.src > 4:
continue
key = (msg.address, msg.dat)
assert key in sent_msgs[msg.src], f"got unexpected msg: {msg.src=} {msg.address=} {msg.dat=}"
# TODO: enable this
#sent_msgs[msg.src].remove(key)
total_recv_count += 1
elif service == "pandaStates":
assert len(m.pandaStates) == 1
ps = m.pandaStates[0]
assert ps.uptime < 1000
assert ps.pandaType == "tres"
assert ps.ignitionLine
assert not ps.ignitionCan
assert 4000 < ps.voltage < 14000
elif service == "peripheralState":
ps = m.peripheralState
assert ps.pandaType == "tres"
assert 4000 < ps.voltage < 14000
assert 50 < ps.current < 1000
assert ps.fanSpeedRpm < 10000
time.sleep(0.5)
et = time.monotonic() - st
print("\n======== timing report ========")
for service, times in ts.items():
dts = np.diff(times)/1e6
print(service.ljust(17), f"{np.mean(dts):7.2f} {np.min(dts):7.2f} {np.max(dts):7.2f}")
with subtests.test(msg="timing check", service=service):
edt = 1e3 / SERVICE_LIST[service].frequency
assert edt*0.9 < np.mean(dts) < edt*1.1
assert np.max(dts) < edt*8
assert np.min(dts) < edt
assert len(dts) >= ((et-0.5)*SERVICE_LIST[service].frequency*0.8)
with subtests.test(msg="CAN traffic"):
print(f"Sent {total_sent_count} CAN messages, got {total_recv_count} back. {total_recv_count/(total_sent_count+1e-4):.2%} received")
assert total_recv_count > 20