forked from IQ.Lvbs/IQ.Pilot
IQ.Pilot Release Commit @ d2ce8a8
This commit is contained in:
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# eGPU dock bring-up runbook
|
||||
|
||||
Our flasher is a port of comma's known-working one, byte-identical firmware
|
||||
bundle, but it has never touched real hardware. Order matters: everything
|
||||
read-only first, evidence at every step.
|
||||
|
||||
Run everything as root from the repo root on the device, dock on the USB-C
|
||||
port, car ignition off.
|
||||
|
||||
## 1. Read-only probe (safe, run first, share the output)
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/dock_probe.py
|
||||
|
||||
Expected on a dock previously flashed by stock openpilot: product matches the
|
||||
bundled `custom ed4e39b7-CLEAN`, USB3 speed, PCIe link L0, stable config read.
|
||||
Any other result: stop and send the output before proceeding.
|
||||
|
||||
## 2. Flash-path validation (writes, but writes the same bytes)
|
||||
|
||||
A stock-flashed dock already runs our exact bundled firmware, so the
|
||||
no-op path proves version detection:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py
|
||||
|
||||
Expected: "firmware matches" and no write. Then exercise the full write path
|
||||
by reflashing the identical image:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py --force
|
||||
|
||||
This backs up the per-unit config page to /data/egpu_dock_config/ first and
|
||||
verifies every sector; identical bytes make it the lowest-risk possible
|
||||
full-path test. Re-run step 1 after; product string and config sha must be
|
||||
unchanged.
|
||||
|
||||
## 3. Runtime
|
||||
|
||||
Set `IQEgpuEnabled`, go onroad (bench is fine), and confirm iqegpumodeld
|
||||
downloads/compiles and the selector reports UsbGpu* status. The runtime gate
|
||||
requires the exact bundled firmware product string, so a dock that failed
|
||||
step 2 will be treated as absent by design.
|
||||
|
||||
## If anything goes wrong
|
||||
|
||||
The dock falling back to the ROM bootloader (product "USB 3.2 PCIe
|
||||
TinyEnclosure" or AS2462*) is recoverable: flash.py handles ROM recovery, and
|
||||
the config backup from step 2 is on disk. Do not improvise register writes;
|
||||
capture output and stop.
|
||||
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock.flash import (
|
||||
Flash, RomFallback, bundled_version, find_dock, in_rom_bootloader, link_up, stable_read,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock enumerated")
|
||||
return 1
|
||||
print(f"dock at {path}")
|
||||
print(f" vid:pid {vid_pid[0]}:{vid_pid[1]}")
|
||||
print(f" product {product!r}")
|
||||
print(f" bundled {bundled_version()!r}")
|
||||
print(f" match {product == bundled_version()}")
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
print(f" usb speed {speed} Mbps ({'USB3' if speed >= 5000 else 'USB2 - register reads capped at 64B'})")
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
print(" state ROM bootloader (config page lost or firmware invalid)")
|
||||
return 2
|
||||
print(f" pcie link {'L0 (trained)' if link_up() else 'not trained'}")
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
flash.connect()
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
print(f" config sha256={hashlib.sha256(config).hexdigest()[:16]} "
|
||||
f"(stable over 3 reads, {sum(1 for b in config if b != 0xFF)} non-blank bytes)")
|
||||
except (RomFallback, OSError, RuntimeError, TimeoutError) as e:
|
||||
print(f" config read failed: {type(e).__name__}: {e}")
|
||||
return 3
|
||||
finally:
|
||||
flash.close()
|
||||
print("all read-only checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
Binary file not shown.
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
VID_PIDS = (("add1", "0001"), ("3801", "0001"))
|
||||
ROM_VID_PIDS = (("174c", "2464"), ("174c", "2463"))
|
||||
ROM_PRODUCT = "USB 3.2 PCIe TinyEnclosure"
|
||||
FIRMWARE_PATH = Path(__file__).with_name("firmware_wrapped.bin")
|
||||
CONFIG_DIR = "/data/egpu_dock_config"
|
||||
LEGACY_CONFIG_DIR = "/data/chestnut_config"
|
||||
PM_PATHS = ("/sys/bus/platform/devices/a800000.ssusb", "/sys/bus/platform/devices/a600000.ssusb",
|
||||
"/sys/bus/usb/devices/usb4")
|
||||
VBUS_PATH = "/sys/kernel/debug/regulator/smb2-vbus/enable"
|
||||
IMAGE_OFFSET = 0x100
|
||||
SECTOR, PAGE = 4096, 128
|
||||
MAX_REGISTER_READ_SIZE = 255
|
||||
MAX_CODE_SIZE = 0x10000
|
||||
FLASH_BUDGET = 600.0
|
||||
USBDEVFS_CONTROL = 0xC0185500
|
||||
USBDEVFS_BULK = 0xC0185502
|
||||
USBDEVFS_SETINTERFACE = 0x80085504
|
||||
USBDEVFS_SETCONFIGURATION = 0x80045505
|
||||
USBDEVFS_CLAIMINTERFACE = 0x8004550F
|
||||
USBDEVFS_RESET = 0x5514
|
||||
USBDEVFS_CLEAR_HALT = 0x80045515
|
||||
|
||||
_deadline = float("inf")
|
||||
|
||||
|
||||
def check_budget():
|
||||
if time.monotonic() > _deadline:
|
||||
raise TimeoutError(f"flash did not converge within {FLASH_BUDGET:g}s")
|
||||
|
||||
|
||||
class Ctrl(ctypes.Structure):
|
||||
_fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8),
|
||||
("value", ctypes.c_uint16), ("index", ctypes.c_uint16),
|
||||
("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32),
|
||||
("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class Bulk(ctypes.Structure):
|
||||
_fields_ = [("ep", ctypes.c_uint), ("len", ctypes.c_uint),
|
||||
("timeout", ctypes.c_uint), ("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class RomFallback(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_dock():
|
||||
found = []
|
||||
for d in glob.glob("/sys/bus/usb/devices/*"):
|
||||
try:
|
||||
with open(d + "/idVendor") as fv, open(d + "/idProduct") as fp:
|
||||
vid_pid = (fv.read().strip(), fp.read().strip())
|
||||
if vid_pid in VID_PIDS + ROM_VID_PIDS:
|
||||
with open(d + "/product") as fpr:
|
||||
found.append((d, vid_pid, fpr.read().strip()))
|
||||
except OSError:
|
||||
pass
|
||||
if len(found) > 1:
|
||||
raise RuntimeError(f"expected one eGPU dock, found {len(found)}")
|
||||
return found[0] if found else (None, None, None)
|
||||
|
||||
|
||||
def in_rom_bootloader(vid_pid, product):
|
||||
return vid_pid in ROM_VID_PIDS or product == ROM_PRODUCT or (product or "").startswith("AS2462")
|
||||
|
||||
|
||||
def disable_runtime_pm(path):
|
||||
control = os.path.join(path, "power/control")
|
||||
if not os.path.exists(control):
|
||||
return
|
||||
with open(control, "w") as f:
|
||||
f.write("on\n")
|
||||
with open(control) as fh:
|
||||
applied = fh.read().strip()
|
||||
if applied != "on":
|
||||
raise RuntimeError(f"could not disable USB runtime PM: {control}")
|
||||
delay = os.path.join(path, "power/autosuspend_delay_ms")
|
||||
if os.path.exists(delay):
|
||||
with open(delay, "w") as f:
|
||||
f.write("-1\n")
|
||||
|
||||
|
||||
def unbind_drivers(path):
|
||||
for interface in glob.glob(path + ":*"):
|
||||
driver = interface + "/driver"
|
||||
if os.path.islink(driver):
|
||||
with open(os.path.realpath(driver) + "/unbind", "w") as f:
|
||||
f.write(os.path.basename(interface))
|
||||
|
||||
|
||||
def open_device(path):
|
||||
with open(path + "/busnum") as fb, open(path + "/devnum") as fd_:
|
||||
bus, dev = int(fb.read()), int(fd_.read())
|
||||
return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR)
|
||||
|
||||
|
||||
def link_up() -> bool:
|
||||
try:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
return False
|
||||
fd = open_device(path)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0x40, 0xF3, 1, 0, 0, 2000, None))
|
||||
buf = (ctypes.c_ubyte * 1)()
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf[0] == 0x78
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def claim_interface(path, setup=False):
|
||||
disable_runtime_pm(path)
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETCONFIGURATION, struct.pack("I", 1))
|
||||
fcntl.ioctl(fd, USBDEVFS_CLAIMINTERFACE, struct.pack("I", 0))
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETINTERFACE, struct.pack("II", 0, 0))
|
||||
except OSError as e:
|
||||
os.close(fd)
|
||||
if e.errno == errno.EBUSY:
|
||||
raise RuntimeError("eGPU dock is in use, stop the model/GPU processes before flashing") from e
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
class Flash:
|
||||
def __init__(self):
|
||||
self.fd = -1
|
||||
self.max_register_read_size = MAX_REGISTER_READ_SIZE
|
||||
|
||||
def close(self):
|
||||
if self.fd >= 0:
|
||||
os.close(self.fd)
|
||||
self.fd = -1
|
||||
|
||||
def connect(self, timeout=5.0):
|
||||
self.close()
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
path, vid_pid, product = find_dock()
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
raise RomFallback("eGPU dock fell back to the ROM bootloader")
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
except (OSError, ValueError):
|
||||
speed = 0
|
||||
self.max_register_read_size = 64 if speed < 5000 else MAX_REGISTER_READ_SIZE
|
||||
self.fd = claim_interface(path)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"eGPU dock did not enumerate within {timeout:g}s")
|
||||
|
||||
def reg_write(self, addr, value):
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0x40, 0xE5, addr & 0xFFFF, value & 0xFFFF, 0, 2000, None))
|
||||
|
||||
def reg_read(self, addr, length=1):
|
||||
buf = (ctypes.c_ubyte * length)()
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0xC0, 0xE4, addr & 0xFFFF, 0, length, 2000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return bytes(buf)
|
||||
|
||||
def write_buffer(self, data):
|
||||
for i, value in enumerate(data):
|
||||
self.reg_write(0x7000 + i, value)
|
||||
|
||||
def wait_controller(self, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.reg_read(0xC8A9)[0] & 1:
|
||||
return
|
||||
raise TimeoutError("flash controller timeout")
|
||||
|
||||
def transaction(self, command, addr=0, length=0, addr_len=0x07, mode=0):
|
||||
for reg, value in ((0xC8AD, mode), (0xC8AE, 0), (0xC8AF, 0), (0xC8AA, command), (0xC8AC, addr_len),
|
||||
(0xC8A1, addr), (0xC8A2, addr >> 8), (0xC8AB, addr >> 16), (0xC8A3, length >> 8), (0xC8A4, length)):
|
||||
self.reg_write(reg, value & 0xFF)
|
||||
self.reg_write(0xC8A9, 1)
|
||||
self.wait_controller()
|
||||
for _ in range(4):
|
||||
self.reg_write(0xC8AD, 0)
|
||||
|
||||
def write_enable(self):
|
||||
for reg, value in ((0xC8AD, 0), (0xC8AA, 0x06), (0xC8AC, 0x04), (0xC8A3, 0), (0xC8A4, 0), (0xC8A9, 1)):
|
||||
self.reg_write(reg, value)
|
||||
self.wait_controller()
|
||||
|
||||
def status(self):
|
||||
self.transaction(0x05, length=1, addr_len=0x04)
|
||||
return self.reg_read(0x7000)[0]
|
||||
|
||||
def wait_write_done(self, timeout=10.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.status() & 1:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise TimeoutError("SPI flash WIP timeout")
|
||||
|
||||
def init(self):
|
||||
self.reg_write(0xCC33, 0x04)
|
||||
self.reg_write(0xCA81, self.reg_read(0xCA81)[0] | 1)
|
||||
self.reg_write(0xC805, 0x02)
|
||||
self.reg_write(0xC8A6, 0x04)
|
||||
for _ in range(5):
|
||||
self.write_enable()
|
||||
self.write_buffer(bytes(4))
|
||||
self.transaction(0x01, length=1, addr_len=0x04, mode=1)
|
||||
time.sleep(0.01)
|
||||
if not self.status() & 0x1C:
|
||||
return
|
||||
raise RuntimeError("could not clear SPI block protection")
|
||||
|
||||
def read(self, addr, length):
|
||||
out = bytearray()
|
||||
while len(out) < length:
|
||||
n = min(4096, length - len(out))
|
||||
self.transaction(0x03, addr + len(out), max(4096, n))
|
||||
for off in range(0, n, self.max_register_read_size):
|
||||
out += self.reg_read(0x7000 + off, min(self.max_register_read_size, n - off))
|
||||
return bytes(out)
|
||||
|
||||
def erase_sector(self, addr):
|
||||
self.write_enable()
|
||||
self.transaction(0x20, addr)
|
||||
self.wait_write_done()
|
||||
|
||||
def program(self, addr, data):
|
||||
self.write_buffer(data + bytes((-len(data)) % 4))
|
||||
self.write_enable()
|
||||
self.transaction(0x02, addr, len(data), mode=1)
|
||||
self.wait_write_done()
|
||||
|
||||
|
||||
def validate_image(data):
|
||||
if len(data) < 10:
|
||||
raise ValueError("wrapped firmware is too short")
|
||||
body_len = int.from_bytes(data[:4], "little")
|
||||
if body_len > MAX_CODE_SIZE:
|
||||
raise ValueError(f"wrapped firmware body exceeds {MAX_CODE_SIZE} bytes")
|
||||
if len(data) != body_len + 10 or data[4 + body_len] != 0xA5:
|
||||
raise ValueError("invalid wrapped firmware length or magic")
|
||||
body = data[4:4 + body_len]
|
||||
if data[5 + body_len] != sum(body) & 0xFF:
|
||||
raise ValueError("invalid wrapped firmware checksum")
|
||||
if data[6 + body_len:] != zlib.crc32(body).to_bytes(4, "little"):
|
||||
raise ValueError("invalid wrapped firmware CRC")
|
||||
|
||||
|
||||
def image_product(image):
|
||||
match = re.search(rb"custom [0-9a-f]{8}-CLEAN", image)
|
||||
if match is None:
|
||||
raise ValueError("no product string in wrapped firmware")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
def reconnect(flash):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
check_budget()
|
||||
try:
|
||||
flash.connect()
|
||||
flash.init()
|
||||
return
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"waiting for eGPU dock (attempt {attempt}): {e}", flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def with_retries(flash, label, operation):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
return operation()
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
check_budget()
|
||||
print(f"{label} attempt {attempt}: {e}", flush=True)
|
||||
reconnect(flash)
|
||||
|
||||
|
||||
def stable_read(flash, addr, length, count=2):
|
||||
def read():
|
||||
reads = [flash.read(addr, length) for _ in range(count)]
|
||||
if any(x != reads[0] for x in reads[1:]):
|
||||
raise RuntimeError(f"unstable flash read at 0x{addr:05x}")
|
||||
return reads[0]
|
||||
return with_retries(flash, f"read 0x{addr:05x}", read)
|
||||
|
||||
|
||||
def program_sector(flash, addr, target):
|
||||
def program():
|
||||
flash.erase_sector(addr)
|
||||
if flash.read(addr, SECTOR) != bytes([0xFF]) * SECTOR:
|
||||
raise RuntimeError("sector erase verification failed")
|
||||
for off in range(0, SECTOR, PAGE):
|
||||
chunk = target[off:off + PAGE]
|
||||
if chunk != bytes([0xFF]) * len(chunk):
|
||||
flash.program(addr + off, chunk)
|
||||
if flash.read(addr + off, len(chunk)) != chunk:
|
||||
raise RuntimeError(f"page verify failed at 0x{addr + off:05x}")
|
||||
if flash.read(addr, SECTOR) != target:
|
||||
raise RuntimeError("sector verification failed")
|
||||
with_retries(flash, f"sector 0x{addr:05x}", program)
|
||||
|
||||
|
||||
def config_path():
|
||||
return os.path.join(CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def legacy_config_path():
|
||||
return os.path.join(LEGACY_CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def saved_config(path, data):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError as e:
|
||||
with open(path, "rb") as fh:
|
||||
backup = fh.read()
|
||||
if len(backup) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {path}") from e
|
||||
if backup != data:
|
||||
print(f"restoring config from {path}", flush=True)
|
||||
return backup
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
return data
|
||||
|
||||
|
||||
def rom_write(image, config):
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock disappeared before recovery")
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_RESET)
|
||||
finally:
|
||||
os.close(fd)
|
||||
time.sleep(3)
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock did not re-enumerate after reset")
|
||||
fd = claim_interface(path, setup=True)
|
||||
for ep in (0x02, 0x81):
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", ep))
|
||||
tag = 0
|
||||
|
||||
def bulk(ep, payload, timeout):
|
||||
buf = ctypes.create_string_buffer(bytes(payload), len(payload))
|
||||
fcntl.ioctl(fd, USBDEVFS_BULK, Bulk(ep, len(payload), timeout, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf.raw
|
||||
|
||||
def cmd(cdb, data=b"", timeout=30000):
|
||||
nonlocal tag
|
||||
tag += 1
|
||||
bulk(0x02, struct.pack("<IIIBBB16s", 0x43425355, tag, len(data), 0, 0, len(cdb), cdb), timeout)
|
||||
if data:
|
||||
bulk(0x02, data, timeout)
|
||||
try:
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EPIPE:
|
||||
raise
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", 0x81))
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
if csw[:4] != b"USBS" or csw[12] != 0:
|
||||
raise RuntimeError(f"ROM flash command {cdb[0]:02x} {cdb[1]:02x} failed")
|
||||
|
||||
print("recovering from the ROM bootloader", flush=True)
|
||||
try:
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 0), config[:0x80])
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 1), config[0x80:])
|
||||
cmd(struct.pack(">BBI", 0xE3, 0x50, min(len(image), 0xFF00)), image[:0xFF00])
|
||||
if len(image) > 0xFF00:
|
||||
cmd(struct.pack(">BBI", 0xE3, 0xD0, len(image) - 0xFF00), image[0xFF00:])
|
||||
cmd(struct.pack(">BB13x", 0xE8, 0x51))
|
||||
finally:
|
||||
os.close(fd)
|
||||
print("recovery flash done", flush=True)
|
||||
|
||||
|
||||
def vbus_write(value):
|
||||
try:
|
||||
with open(VBUS_PATH, "w") as f:
|
||||
f.write(value + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def vbus_cycle():
|
||||
if os.path.exists(VBUS_PATH):
|
||||
vbus_write("0")
|
||||
time.sleep(2)
|
||||
vbus_write("1")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def activate(expected_product):
|
||||
if not os.path.exists(VBUS_PATH):
|
||||
print("no VBUS control, firmware activates on the next dock power cycle", flush=True)
|
||||
return
|
||||
print("power-cycling the eGPU dock VBUS", flush=True)
|
||||
vbus_write("0")
|
||||
disconnected = False
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
disconnected = True
|
||||
break
|
||||
time.sleep(0.2)
|
||||
time.sleep(1)
|
||||
vbus_write("1")
|
||||
if not disconnected:
|
||||
print("dock stayed powered, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
deadline = time.monotonic() + 15.0
|
||||
while time.monotonic() < deadline:
|
||||
_, _, product = find_dock()
|
||||
if product is not None:
|
||||
if product == expected_product:
|
||||
print(f"activated {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"dock re-enumerated with {product!r}, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
print("dock did not re-enumerate, firmware activates on its next power cycle", flush=True)
|
||||
|
||||
|
||||
def defer_signal(signum, _frame):
|
||||
os.write(1, f"signal {signum} deferred until the dock is powered back up\n".encode())
|
||||
|
||||
|
||||
def flash_dock(expected_version=None, force=False):
|
||||
global _deadline
|
||||
|
||||
image = FIRMWARE_PATH.read_bytes()
|
||||
validate_image(image)
|
||||
expected_product = image_product(image)
|
||||
if expected_version is not None and expected_product != f"custom {expected_version}-CLEAN":
|
||||
raise RuntimeError(f"bundled firmware is {expected_product!r}, expected version {expected_version}")
|
||||
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock connected", flush=True)
|
||||
return
|
||||
if product == expected_product and not force:
|
||||
print(f"eGPU dock firmware is up to date ({expected_product})", flush=True)
|
||||
return
|
||||
|
||||
_deadline = time.monotonic() + FLASH_BUDGET
|
||||
for pm_path in PM_PATHS:
|
||||
disable_runtime_pm(pm_path)
|
||||
|
||||
previous = {sig: signal.signal(sig, defer_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
||||
try:
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
if not recover_from_rom(image, expected_product):
|
||||
return
|
||||
force, product = True, None
|
||||
write_image(image, expected_product, product, force)
|
||||
finally:
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
|
||||
def recover_from_rom(image, expected_product):
|
||||
backup = config_path()
|
||||
if not os.path.isfile(backup) and os.path.isfile(legacy_config_path()):
|
||||
backup = legacy_config_path()
|
||||
if not os.path.isfile(backup):
|
||||
raise RuntimeError(f"cannot recover from the ROM bootloader without a config backup at {config_path()}")
|
||||
with open(backup, "rb") as fh:
|
||||
config = fh.read()
|
||||
if len(config) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {backup}")
|
||||
|
||||
committed = False
|
||||
while True:
|
||||
check_budget()
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
if committed:
|
||||
print("dock is offline, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
vbus_cycle()
|
||||
continue
|
||||
if not in_rom_bootloader(vid_pid, product):
|
||||
return True
|
||||
if committed:
|
||||
print("dock stayed powered, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
try:
|
||||
rom_write(image, config)
|
||||
committed = True
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"ROM recovery failed, retrying: {e}", flush=True)
|
||||
vbus_cycle()
|
||||
continue
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def write_image(image, expected_product, product, force):
|
||||
if force:
|
||||
print(f"forced reflash of {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"eGPU dock firmware mismatch: {product!r}; expected {expected_product!r}", flush=True)
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
reconnect(flash)
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
config = saved_config(config_path(), config)
|
||||
image_end = IMAGE_OFFSET + len(image)
|
||||
first_sector = IMAGE_OFFSET & ~(SECTOR - 1)
|
||||
span = (image_end + SECTOR - 1) & ~(SECTOR - 1)
|
||||
current = stable_read(flash, first_sector, span - first_sector)
|
||||
target = bytearray(current)
|
||||
target[:len(config)] = config
|
||||
target[IMAGE_OFFSET - first_sector:image_end - first_sector] = image
|
||||
target = bytes(target)
|
||||
print(f"target {len(image)} bytes at 0x{IMAGE_OFFSET:05x}, sha256={hashlib.sha256(image).hexdigest()}", flush=True)
|
||||
|
||||
if not _still_offroad():
|
||||
raise RuntimeError("device went onroad before any sector was written; aborting flash")
|
||||
|
||||
for addr in range(first_sector, span, SECTOR):
|
||||
off = addr - first_sector
|
||||
wanted = target[off:off + SECTOR]
|
||||
if current[off:off + SECTOR] == wanted:
|
||||
print(f"sector 0x{addr:05x}: unchanged", flush=True)
|
||||
else:
|
||||
print(f"sector 0x{addr:05x}: programming", flush=True)
|
||||
program_sector(flash, addr, wanted)
|
||||
|
||||
verified = stable_read(flash, first_sector, span - first_sector, 3)
|
||||
if verified != target:
|
||||
raise RuntimeError("final full-image verification failed")
|
||||
print(f"verified sha256={hashlib.sha256(verified).hexdigest()}", flush=True)
|
||||
finally:
|
||||
flash.close()
|
||||
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def bundled_version() -> str:
|
||||
return image_product(FIRMWARE_PATH.read_bytes())
|
||||
|
||||
|
||||
def _still_offroad() -> bool:
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
return bool(Params().get_bool("IsOffroad"))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def dock_needs_flash(usb_devices: list[dict]) -> bool:
|
||||
try:
|
||||
expected = bundled_version()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
ids = tuple(tuple(int(x, 16) for x in p) for p in VID_PIDS + ROM_VID_PIDS)
|
||||
return any((d.get("vendorId"), d.get("productId")) in ids and d.get("product") != expected
|
||||
for d in usb_devices)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="check and flash the bundled eGPU dock firmware")
|
||||
parser.add_argument("version", nargs="?", help="expected firmware version hash")
|
||||
parser.add_argument("--force", action="store_true", help="reflash even when the version matches")
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError("flash.py must run as root")
|
||||
flash_dock(expected_version=args.version, force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import time
|
||||
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT, is_egpu_usb_device
|
||||
|
||||
EGPU_POWERED_VOLTAGE = 5000
|
||||
GPU_TEMP_LIMIT = 110.
|
||||
MEMORY_TEMP_LIMIT = 108.
|
||||
TEMP_HYSTERESIS = 5.
|
||||
FAN_START_GPU_TEMP = 60.
|
||||
FAN_STOP_GPU_TEMP = 50.
|
||||
FAN_START_MEMORY_TEMP = 70.
|
||||
FAN_STOP_MEMORY_TEMP = 60.
|
||||
FAN_STALLED_RPM = 250
|
||||
|
||||
|
||||
class EgpuDockStatus:
|
||||
def __init__(self):
|
||||
self.offroad = True
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.overheated = False
|
||||
self.fans_obstructed = False
|
||||
self.usb_seen = False
|
||||
self.usb_failed = False
|
||||
|
||||
def update(self, offroad, usb_state, firmware_failed, model_loading, model_active, compiled, state, set_alert):
|
||||
detected = [d for d in usb_state if is_egpu_usb_device(d["vendorId"], d["productId"], include_bootloader=True)]
|
||||
devices = [d for d in detected if is_egpu_usb_device(d["vendorId"], d["productId"])]
|
||||
firmware_ok = len(devices) == 1 and devices[0]["product"] == EGPU_DOCK_FW_PRODUCT
|
||||
|
||||
if self.offroad and not offroad:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.usb_seen = firmware_ok
|
||||
self.usb_failed = False
|
||||
|
||||
self.model_loading_seen |= model_loading
|
||||
self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None
|
||||
|
||||
if not offroad and self.usb_seen and not firmware_ok:
|
||||
self.usb_failed = True
|
||||
|
||||
if not offroad and self.model_attempted and state is not None:
|
||||
power_lost = state.supplyFault or state.supplyVoltage < EGPU_POWERED_VOLTAGE
|
||||
self.link_failures = self.link_failures + 1 if state.pcieLtssm != 0x78 else 0
|
||||
self.pcie_failed |= self.link_failures >= 2 or power_lost
|
||||
self.power_lost |= power_lost
|
||||
|
||||
if self.pcie_failed and self.power_lost and state is not None:
|
||||
self.power_restored |= not state.supplyFault and state.supplyVoltage >= EGPU_POWERED_VOLTAGE
|
||||
if self.usb_failed:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
|
||||
if state is not None:
|
||||
gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
memory_limit = MEMORY_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
self.overheated = state.tempC >= gpu_limit or state.memoryTempC >= memory_limit
|
||||
fan_hot = (state.tempC >= (FAN_STOP_GPU_TEMP if self.fans_obstructed else FAN_START_GPU_TEMP) or
|
||||
state.memoryTempC >= (FAN_STOP_MEMORY_TEMP if self.fans_obstructed else FAN_START_MEMORY_TEMP))
|
||||
self.fans_obstructed = fan_hot and state.fanSpeedRpm < FAN_STALLED_RPM
|
||||
|
||||
slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000
|
||||
set_alert("Offroad_EgpuNotDetected", self.usb_failed)
|
||||
set_alert("Offroad_EgpuFansObstructed", self.fans_obstructed)
|
||||
set_alert("Offroad_EgpuOverheated", self.overheated)
|
||||
set_alert("Offroad_EgpuUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None)
|
||||
if self.power_lost:
|
||||
pcie_action = "12V power was interrupted, possibly by engine start-stop. "
|
||||
pcie_action += ("Cycle ignition to reload the model." if self.power_restored else
|
||||
"Check 12V, then cycle ignition to reload the model.")
|
||||
else:
|
||||
pcie_action = "Check 12V connection."
|
||||
set_alert("Offroad_EgpuPcieUnavailable", self.pcie_failed, pcie_action)
|
||||
set_alert("Offroad_EgpuUncompiled", offroad and firmware_ok and not compiled)
|
||||
set_alert("Offroad_EgpuUpdateFailed", offroad and firmware_failed)
|
||||
self.offroad = offroad
|
||||
@@ -3,24 +3,13 @@ import numpy as np
|
||||
|
||||
|
||||
class FanController:
|
||||
def __init__(self) -> None:
|
||||
self.last_ignition = False
|
||||
|
||||
def update(self, cur_temp: float, ignition: bool, max_cool: bool = False) -> int:
|
||||
if max_cool:
|
||||
self.last_ignition = ignition
|
||||
return 100
|
||||
|
||||
if cur_temp < 70.0:
|
||||
fan_pwr_out = 0
|
||||
elif cur_temp > 85.0:
|
||||
fan_pwr_out = 100
|
||||
else:
|
||||
# 70°C → 0%, 85°C → 80%, target 75°C
|
||||
fan_pwr_out = int(np.interp(cur_temp, [70.0, 85.0], [0, 80]))
|
||||
fan_pwr_out = int(np.interp(cur_temp, [70.0, 85.0, 90.0], [0, 80, 100]))
|
||||
|
||||
if not ignition:
|
||||
fan_pwr_out = min(fan_pwr_out, 30)
|
||||
|
||||
self.last_ignition = ignition
|
||||
return fan_pwr_out
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import fcntl
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
@@ -16,10 +18,14 @@ from iqpilot.cereal.services import SERVICE_LIST
|
||||
from iqpilot.common.iq_perf import PerfSample, PerfTraceEmitter
|
||||
from iqpilot.common.utils import strip_deprecated_keys
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.realtime import DT_HW
|
||||
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from iqpilot.system.hardware import HARDWARE, TICI, AGNOS
|
||||
from iqpilot.system.hardware.egpu_dock.flash import dock_needs_flash
|
||||
from iqpilot.system.hardware.egpu_dock.status import EgpuDockStatus
|
||||
from iqpilot.system.hardware.usb import get_link_error_count, get_usb_state, set_usb_state, usb3_lane
|
||||
from iqpilot.system.loggerd.config import get_available_percent
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.hardware.power_monitoring import PowerMonitoring, VBATT_LOW_POWER_EXIT
|
||||
@@ -41,7 +47,8 @@ CAN_STARTUP_RECOVERY_MAX_ATTEMPTS = 2
|
||||
|
||||
ThermalBand = namedtuple("ThermalBand", ['min_temp', 'max_temp'])
|
||||
HardwareState = namedtuple("HardwareState", ['network_type', 'network_info', 'network_strength', 'network_stats',
|
||||
'network_metered', 'modem_temps'])
|
||||
'network_metered', 'modem_temps', 'usb_state', 'usb_link_errors',
|
||||
'usb3_lane'])
|
||||
|
||||
# List of thermal bands. We will stay within this region as long as we are within the bounds.
|
||||
# When exiting the bounds, we'll jump to the lower or higher band. Bands are ordered in the dict.
|
||||
@@ -161,6 +168,56 @@ class _CarParamsCache:
|
||||
|
||||
|
||||
|
||||
class EgpuDockFlasher:
|
||||
"""Flash the dock's firmware offroad when it does not match what we ship.
|
||||
|
||||
Same policy as stock: the model runtime ignores a dock until its product
|
||||
string matches, so a mismatched dock is unusable until this runs. Bounded
|
||||
attempts, offroad only, one flash in flight at a time.
|
||||
"""
|
||||
MAX_ATTEMPTS = 3
|
||||
RETRY_INTERVAL = 20.
|
||||
|
||||
def __init__(self):
|
||||
self.thread: threading.Thread | None = None
|
||||
self.attempts = 0
|
||||
self.last_attempt = 0.
|
||||
self.flashed = False
|
||||
self.mismatch = False
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return (self.mismatch and self.attempts >= self.MAX_ATTEMPTS
|
||||
and self.thread is not None and not self.thread.is_alive() and not self.flashed)
|
||||
|
||||
def flash(self) -> None:
|
||||
ret = subprocess.run(["sudo", sys.executable,
|
||||
os.path.join(BASEDIR, "iqpilot/system/hardware/egpu_dock/flash.py")],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
|
||||
cloudlog.event("egpu dock flash done", returncode=ret.returncode, output=ret.stdout[-1000:],
|
||||
error=ret.returncode != 0)
|
||||
self.flashed = ret.returncode == 0
|
||||
|
||||
def update(self, offroad: bool, usb_state: list[dict]) -> None:
|
||||
self.mismatch = dock_needs_flash(usb_state)
|
||||
if not self.mismatch:
|
||||
self.flashed = False
|
||||
return
|
||||
|
||||
if not offroad or self.flashed or self.attempts >= self.MAX_ATTEMPTS:
|
||||
return
|
||||
if self.thread is not None and self.thread.is_alive():
|
||||
return
|
||||
if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL:
|
||||
return
|
||||
|
||||
self.attempts += 1
|
||||
self.last_attempt = time.monotonic()
|
||||
cloudlog.warning(f"egpu dock firmware out of date, flashing (attempt {self.attempts})")
|
||||
self.thread = threading.Thread(target=self.flash, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
|
||||
def set_offroad_alert_if_changed(offroad_alert: str, show_alert: bool, extra_text: str | None=None):
|
||||
if prev_offroad_states.get(offroad_alert, None) == (show_alert, extra_text):
|
||||
return
|
||||
@@ -254,6 +311,9 @@ def hw_state_thread(end_event, hw_queue):
|
||||
network_stats={'wwanTx': tx, 'wwanRx': rx},
|
||||
network_metered=HARDWARE.get_network_metered(network_type),
|
||||
modem_temps=modem_temps,
|
||||
usb_state=get_usb_state(),
|
||||
usb_link_errors=get_link_error_count(),
|
||||
usb3_lane=usb3_lane(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -280,7 +340,7 @@ def hw_state_thread(end_event, hw_queue):
|
||||
|
||||
def hardware_thread(end_event, hw_queue) -> None:
|
||||
pm = messaging.PubMaster(['deviceState', 'iqPerfTrace'])
|
||||
sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "carState"], poll="pandaStates")
|
||||
sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "carState", "egpuDockState"], poll="pandaStates")
|
||||
perf = PerfTraceEmitter("hardwared", pubmaster=pm)
|
||||
|
||||
count = 0
|
||||
@@ -306,6 +366,9 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
network_strength=NetworkStrength.unknown,
|
||||
network_stats={'wwanTx': -1, 'wwanRx': -1},
|
||||
modem_temps=[],
|
||||
usb_state=[],
|
||||
usb_link_errors=0,
|
||||
usb3_lane="unknown",
|
||||
)
|
||||
|
||||
all_temp_filter = FirstOrderFilter(0., TEMP_TAU, DT_HW, initialized=False)
|
||||
@@ -332,6 +395,8 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
thermal_config = HARDWARE.get_thermal_config()
|
||||
|
||||
fan_controller = FanController()
|
||||
egpu_dock_flasher = EgpuDockFlasher()
|
||||
egpu_dock_status = EgpuDockStatus()
|
||||
|
||||
while not end_event.is_set():
|
||||
sm.update(PANDA_STATES_TIMEOUT)
|
||||
@@ -427,6 +492,14 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
msg.deviceState.networkInfo = last_hw_state.network_info
|
||||
|
||||
msg.deviceState.modemTempC = last_hw_state.modem_temps
|
||||
set_usb_state(msg.deviceState, last_hw_state.usb_state, last_hw_state.usb_link_errors,
|
||||
last_hw_state.usb3_lane)
|
||||
egpu_dock_flasher.update(started_ts is None, last_hw_state.usb_state)
|
||||
egpu_valid = sm.alive["egpuDockState"] and sm.valid["egpuDockState"]
|
||||
egpu_dock_status.update(started_ts is None, last_hw_state.usb_state, egpu_dock_flasher.failed,
|
||||
params.get_bool("UsbGpuLoading"), params.get("UsbGpuActive"),
|
||||
params.get_bool("UsbGpuCompiled"),
|
||||
sm["egpuDockState"] if egpu_valid else None, set_offroad_alert_if_changed)
|
||||
|
||||
msg.deviceState.screenBrightnessPercent = HARDWARE.get_screen_brightness()
|
||||
|
||||
|
||||
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
The eGPU dock flasher writes SPI flash, so the parts that decide WHETHER and
|
||||
WHAT to write are pinned here. The transfer path itself needs the hardware; the
|
||||
image validator, product parsing, config preservation and the needs-flash
|
||||
decision do not, and those are what stop a bad write.
|
||||
"""
|
||||
import os
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock import flash as f
|
||||
|
||||
|
||||
def _wrap(body: bytes, *, magic=0xA5, checksum=None, crc=None, body_len=None) -> bytes:
|
||||
n = len(body) if body_len is None else body_len
|
||||
cs = (sum(body) & 0xFF) if checksum is None else checksum
|
||||
c = zlib.crc32(body).to_bytes(4, "little") if crc is None else crc
|
||||
return n.to_bytes(4, "little") + body + bytes([magic, cs]) + c
|
||||
|
||||
|
||||
def test_bundled_firmware_is_valid_and_named():
|
||||
image = f.FIRMWARE_PATH.read_bytes()
|
||||
f.validate_image(image) # raises if the shipped blob is corrupt
|
||||
assert f.image_product(image).startswith("custom ")
|
||||
assert f.image_product(image).endswith("-CLEAN")
|
||||
assert f.bundled_version() == f.image_product(image)
|
||||
|
||||
|
||||
def test_validate_rejects_corruption():
|
||||
body = b"custom deadbeef-CLEAN" + bytes(64)
|
||||
f.validate_image(_wrap(body)) # the good case
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(b"\x00" * 4) # too short
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, magic=0x00)) # bad magic
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, checksum=0x00))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, crc=b"\x00\x00\x00\x00"))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, body_len=len(body) + 1)) # length disagrees
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image((f.MAX_CODE_SIZE + 1).to_bytes(4, "little") + bytes(16))
|
||||
|
||||
|
||||
def test_image_product_requires_a_version_string():
|
||||
with pytest.raises(ValueError):
|
||||
f.image_product(b"no version here")
|
||||
|
||||
|
||||
def test_saved_config_preserves_the_first_backup(tmp_path):
|
||||
# the config page is per-unit; a reflash must rewrite the ORIGINAL, never the
|
||||
# bytes read back from a half-written dock
|
||||
p = str(tmp_path / "dock.bin")
|
||||
original = bytes(range(256))
|
||||
assert f.saved_config(p, original) == original
|
||||
# later flash reads something different -> the stored original wins
|
||||
assert f.saved_config(p, bytes(256)) == original
|
||||
with open(p, "rb") as fh:
|
||||
assert fh.read() == original
|
||||
|
||||
|
||||
def test_saved_config_rejects_wrong_size_backup(tmp_path):
|
||||
p = str(tmp_path / "dock.bin")
|
||||
with open(p, "wb") as fh:
|
||||
fh.write(b"\x00" * 8)
|
||||
with pytest.raises(RuntimeError):
|
||||
f.saved_config(p, bytes(256))
|
||||
|
||||
|
||||
def test_needs_flash_only_for_a_dock_on_wrong_firmware():
|
||||
expected = f.bundled_version()
|
||||
vid, pid = (int(x, 16) for x in f.VID_PIDS[0])
|
||||
rom_vid, rom_pid = (int(x, 16) for x in f.ROM_VID_PIDS[0])
|
||||
|
||||
assert not f.dock_needs_flash([])
|
||||
assert not f.dock_needs_flash([{"vendorId": 0x1234, "productId": 0x5678, "product": "something else"}])
|
||||
assert not f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": expected}])
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
# a ROM-mode board always needs flashing
|
||||
assert f.dock_needs_flash([{"vendorId": rom_vid, "productId": rom_pid, "product": f.ROM_PRODUCT}])
|
||||
|
||||
|
||||
def test_both_shipped_ids_trigger_the_check():
|
||||
for pair in f.VID_PIDS:
|
||||
vid, pid = (int(x, 16) for x in pair)
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
|
||||
|
||||
def test_rom_detection():
|
||||
assert f.in_rom_bootloader(f.ROM_VID_PIDS[0], "anything")
|
||||
assert f.in_rom_bootloader(("add1", "0001"), f.ROM_PRODUCT)
|
||||
assert f.in_rom_bootloader(("add1", "0001"), "AS2462something")
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), f.bundled_version())
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), None)
|
||||
|
||||
|
||||
def test_config_paths_are_per_host_and_have_a_legacy_fallback():
|
||||
host = os.uname().nodename
|
||||
assert f.config_path().endswith(f"{host}.bin")
|
||||
assert f.config_path().startswith(f.CONFIG_DIR)
|
||||
# a dock flashed on this device by stock openpilot left its backup elsewhere
|
||||
assert f.legacy_config_path().startswith(f.LEGACY_CONFIG_DIR)
|
||||
assert f.legacy_config_path() != f.config_path()
|
||||
|
||||
|
||||
def test_we_do_not_autoflash():
|
||||
# upstream flashes from hardwared automatically; ours must stay deliberate
|
||||
# until it has been validated against a real dock
|
||||
import subprocess
|
||||
root = os.path.join(os.path.dirname(f.__file__), "..", "..", "..")
|
||||
hits = subprocess.run(["grep", "-rnI", "--exclude-dir=__pycache__", "flash_dock", os.path.join(root, "system"),
|
||||
os.path.join(root, "iqpilot")], capture_output=True, text=True).stdout
|
||||
callers = [ln for ln in hits.splitlines() if "egpu_dock/flash.py" not in ln and "test_" not in ln]
|
||||
assert callers == [], f"unexpected automatic flash caller: {callers}"
|
||||
|
||||
|
||||
def test_runtime_fw_gate_is_pinned_to_the_bundled_firmware():
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT
|
||||
assert EGPU_DOCK_FW_PRODUCT == f.bundled_version()
|
||||
|
||||
|
||||
def test_register_reads_default_to_superspeed_size():
|
||||
assert f.MAX_REGISTER_READ_SIZE == 255
|
||||
assert f.Flash().max_register_read_size == f.MAX_REGISTER_READ_SIZE
|
||||
|
||||
|
||||
def test_link_up_is_false_without_a_dock():
|
||||
assert f.link_up() is False
|
||||
@@ -1,57 +1,41 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.system.hardware.fan_controller import FanController
|
||||
|
||||
ALL_CONTROLLERS = [FanController]
|
||||
|
||||
def patched_controller(mocker, controller_class):
|
||||
mocker.patch("os.system", new=mocker.Mock())
|
||||
return controller_class()
|
||||
|
||||
class TestFanController:
|
||||
def wind_up(self, controller, ignition=True):
|
||||
for _ in range(1000):
|
||||
controller.update(100, ignition)
|
||||
def test_ramp_anchors(self):
|
||||
c = FanController()
|
||||
assert c.update(60, True) == 0
|
||||
assert c.update(70, True) == 0
|
||||
assert c.update(85, True) == 80
|
||||
assert c.update(90, True) == 100
|
||||
assert c.update(100, True) == 100
|
||||
|
||||
def wind_down(self, controller, ignition=False):
|
||||
for _ in range(1000):
|
||||
controller.update(10, ignition)
|
||||
def test_ramp_is_monotonic_and_continuous(self):
|
||||
c = FanController()
|
||||
temps = np.arange(50.0, 105.0, 0.25)
|
||||
outs = [c.update(t, True) for t in temps]
|
||||
assert all(b >= a for a, b in zip(outs, outs[1:]))
|
||||
# no step may exceed the steepest segment's slope (4 %/deg) over a 0.25 deg move
|
||||
assert max(b - a for a, b in zip(outs, outs[1:])) <= 2
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_hot_onroad(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller)
|
||||
assert controller.update(100, True) >= 70
|
||||
def test_hot_onroad(self):
|
||||
assert FanController().update(100, True) >= 70
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_offroad_limits(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller)
|
||||
assert controller.update(100, False) <= 30
|
||||
def test_offroad_capped(self):
|
||||
c = FanController()
|
||||
for t in (60, 75, 85, 100):
|
||||
assert c.update(t, False) <= 30
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_no_fan_wear(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller)
|
||||
assert controller.update(10, False) == 0
|
||||
def test_no_fan_wear(self):
|
||||
assert FanController().update(10, False) == 0
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_limited(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller, True)
|
||||
assert controller.update(100, True) == 100
|
||||
def test_max_cool(self):
|
||||
c = FanController()
|
||||
assert c.update(80, True, True) == 100
|
||||
assert c.update(80, False, True) == 100
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_max_cool(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller)
|
||||
assert controller.update(80, True, True) == 100
|
||||
assert controller.update(80, False, True) == 100
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_windup_speed(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller, True)
|
||||
for _ in range(10):
|
||||
controller.update(90, True)
|
||||
assert controller.update(90, True) >= 60
|
||||
def test_target_band_has_airflow(self):
|
||||
# the design centers on 75 C; the curve must actually move air there
|
||||
assert 20 <= FanController().update(75, True) <= 40
|
||||
|
||||
267
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
267
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
deviceState.usbState carries a per-device USB bus snapshot plus the ssusb
|
||||
link-error counter (IQ.OS 4.9.1+ `portli`) into every rlog. Drive it off a
|
||||
synthetic sysfs tree so parsing, eGPU dock presence and the link-error
|
||||
plumbing are pinned without hardware.
|
||||
"""
|
||||
from iqpilot.cereal import messaging
|
||||
from iqpilot.system.hardware.usb import (
|
||||
EGPU_DOCK_FW_PRODUCT, EGPU_DOCK_ROM_USB_IDS, EGPU_DOCK_USB_IDS, controller, egpu_dock_present,
|
||||
egpu_dock_ready, get_link_error_count,
|
||||
get_usb_topology, get_usb_state, link_controller, read_hex_counter, set_usb_state, usb3_lane,
|
||||
)
|
||||
|
||||
|
||||
def _mkctrl(root, name="a800000.ssusb", portli="0x00000000"):
|
||||
"""Platform controller dir, mirroring /sys/devices/platform/soc/<x>.ssusb."""
|
||||
ctrl = root / "soc" / name
|
||||
ctrl.mkdir(parents=True)
|
||||
if portli is not None:
|
||||
(ctrl / "portli").write_text(portli + "\n")
|
||||
return ctrl
|
||||
|
||||
|
||||
def _mkdev(root, name, *, vid, pid, busnum=1, devnum=2, speed=5000,
|
||||
manufacturer="ACME", product="Widget", ctrl=None):
|
||||
"""USB device under the controller, symlinked into the bus view like sysfs."""
|
||||
real = (ctrl / "usb1" / name) if ctrl is not None else (root / "bus" / name)
|
||||
real.mkdir(parents=True)
|
||||
(real / "idVendor").write_text(f"{vid:04x}\n")
|
||||
(real / "idProduct").write_text(f"{pid:04x}\n")
|
||||
(real / "busnum").write_text(f"{busnum}\n")
|
||||
(real / "devnum").write_text(f"{devnum}\n")
|
||||
(real / "speed").write_text(f"{speed}\n")
|
||||
(real / "manufacturer").write_text(manufacturer + "\n")
|
||||
(real / "product").write_text(product + "\n")
|
||||
|
||||
bus = root / "bus"
|
||||
bus.mkdir(parents=True, exist_ok=True)
|
||||
link = bus / name
|
||||
if real != link:
|
||||
link.symlink_to(real)
|
||||
return real
|
||||
|
||||
|
||||
def test_missing_sysfs_is_empty(tmp_path):
|
||||
assert get_usb_state(tmp_path / "nope") == []
|
||||
|
||||
|
||||
def test_entries_without_idvendor_are_skipped(tmp_path):
|
||||
(tmp_path / "bus" / "usb1").mkdir(parents=True) # a root hub dir with no idVendor
|
||||
_mkdev(tmp_path, "1-2", vid=0x1234, pid=0x5678)
|
||||
state = get_usb_state(tmp_path / "bus")
|
||||
assert len(state) == 1 and state[0]["vendorId"] == 0x1234
|
||||
|
||||
|
||||
def test_fields_parsed_with_hex_ids(tmp_path):
|
||||
_mkdev(tmp_path, "1-2", vid=0x0BDA, pid=0x8153, busnum=3, devnum=7,
|
||||
speed=480, manufacturer="Realtek", product="USB 10/100 LAN")
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev == {
|
||||
"busnum": 3, "devnum": 7,
|
||||
"vendorId": 0x0BDA, "productId": 0x8153,
|
||||
"speedMbps": 480,
|
||||
"manufacturer": "Realtek", "product": "USB 10/100 LAN",
|
||||
"linkErrorCount": 0, # no controller in this device's path
|
||||
"usb3Lane": "unknown", # not on the type-C port's controller
|
||||
}
|
||||
|
||||
|
||||
def test_unreadable_strings_default_empty(tmp_path):
|
||||
real = _mkdev(tmp_path, "1-2", vid=0x1, pid=0x2)
|
||||
(real / "manufacturer").unlink()
|
||||
(real / "product").unlink()
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["manufacturer"] == "" and dev["product"] == ""
|
||||
|
||||
|
||||
def test_hex_counter_parsing(tmp_path):
|
||||
f = tmp_path / "portli"
|
||||
f.write_text("0x00000000\n")
|
||||
assert read_hex_counter(f) == 0
|
||||
f.write_text("0x0000002a\n")
|
||||
assert read_hex_counter(f) == 42
|
||||
f.write_text("0000002a\n") # bare hex, no 0x prefix
|
||||
assert read_hex_counter(f) == 42
|
||||
f.write_text("garbage\n")
|
||||
assert read_hex_counter(f) == 0
|
||||
assert read_hex_counter(tmp_path / "absent") == 0 # pre-4.9.1 IQ.OS
|
||||
|
||||
|
||||
def test_controller_resolved_from_device(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path)
|
||||
_mkdev(tmp_path, "1-2", vid=0x1, pid=0x2, ctrl=ctrl)
|
||||
assert controller(tmp_path / "bus" / "1-2") == ctrl.resolve()
|
||||
|
||||
|
||||
def test_device_carries_its_controllers_link_errors(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path, portli="0x0000000c")
|
||||
_mkdev(tmp_path, "1-2", vid=0x1234, pid=0x5678, ctrl=ctrl)
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["linkErrorCount"] == 12
|
||||
|
||||
|
||||
def test_controller_count_without_any_enumerated_device(tmp_path):
|
||||
# peripheral mode (eMac gadget link): the peer never enumerates on our side,
|
||||
# so the counter must still be readable off the controller
|
||||
_mkctrl(tmp_path, portli="0x00000005")
|
||||
assert get_usb_state(tmp_path / "bus") == []
|
||||
assert get_link_error_count(tmp_path / "soc") == 5
|
||||
|
||||
|
||||
def test_link_errors_summed_across_controllers(tmp_path):
|
||||
_mkctrl(tmp_path, name="a800000.ssusb", portli="0x00000002")
|
||||
_mkctrl(tmp_path, name="a600000.ssusb", portli="0x00000003")
|
||||
assert get_link_error_count(tmp_path / "soc") == 5
|
||||
|
||||
|
||||
def test_missing_portli_reads_zero(tmp_path):
|
||||
_mkctrl(tmp_path, portli=None) # pre-4.9.1 kernel: file absent
|
||||
assert get_link_error_count(tmp_path / "soc") == 0
|
||||
|
||||
|
||||
def test_set_usb_state_populates_message_and_flags_dock(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path, portli="0x00000007")
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678, speed=480, ctrl=ctrl)
|
||||
_mkdev(tmp_path, "1-2", vid=EGPU_DOCK_USB_IDS[0][0], pid=EGPU_DOCK_USB_IDS[0][1], speed=5000, ctrl=ctrl)
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"), get_link_error_count(tmp_path / "soc"))
|
||||
|
||||
devices = list(msg.deviceState.usbState.devices)
|
||||
assert len(devices) == 2
|
||||
assert {d.speedMbps for d in devices} == {480, 5000}
|
||||
assert all(d.linkErrorCount == 7 for d in devices)
|
||||
assert msg.deviceState.usbState.linkErrorCount == 7
|
||||
assert msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_dock_absent_when_not_plugged(tmp_path):
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678)
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"))
|
||||
assert not msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_both_shipped_dock_usb_ids_detected(tmp_path):
|
||||
# comma ships the dock under two VID/PIDs; only the first was known before
|
||||
for i, (vid, pid) in enumerate(EGPU_DOCK_USB_IDS):
|
||||
root = tmp_path / f"v{i}"
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid)
|
||||
assert egpu_dock_present(root / "bus"), f"{vid:#06x}:{pid:#06x} not detected"
|
||||
|
||||
|
||||
def test_dock_in_rom_mode_is_not_present(tmp_path):
|
||||
# bootloader/ROM state enumerates but cannot serve a GPU until flashed
|
||||
vid, pid = EGPU_DOCK_ROM_USB_IDS[0]
|
||||
_mkdev(tmp_path, "1-1", vid=vid, pid=pid)
|
||||
assert not egpu_dock_present(tmp_path / "bus")
|
||||
|
||||
|
||||
def test_empty_bus_clears_flag():
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, [])
|
||||
assert len(msg.deviceState.usbState.devices) == 0
|
||||
assert msg.deviceState.usbState.linkErrorCount == 0
|
||||
assert not msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_link_error_count_masked_to_16_bits(tmp_path):
|
||||
# the per-device field is UInt16 upstream; a wrapped counter must not overflow it
|
||||
ctrl = _mkctrl(tmp_path, portli="0x0001ffff")
|
||||
_mkdev(tmp_path, "1-2", vid=0x1, pid=0x2, ctrl=ctrl)
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["linkErrorCount"] == 0xFFFF
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"))
|
||||
assert list(msg.deviceState.usbState.devices)[0].linkErrorCount == 0xFFFF
|
||||
|
||||
|
||||
def test_usb_topology_lists_bus_entries(tmp_path):
|
||||
_mkdev(tmp_path, "1-1", vid=0x1, pid=0x2)
|
||||
_mkdev(tmp_path, "1-2", vid=0x3, pid=0x4)
|
||||
assert {"1-1", "1-2"} <= get_usb_topology(tmp_path / "bus")
|
||||
assert get_usb_topology(tmp_path / "nope") == set()
|
||||
|
||||
|
||||
def _mkudc(root, name="a600000.dwc3"):
|
||||
udc = root / "udc" / name
|
||||
udc.mkdir(parents=True, exist_ok=True)
|
||||
(udc / "state").write_text("not attached\n")
|
||||
return root / "udc"
|
||||
|
||||
|
||||
def test_link_controller_derived_from_udc_not_hardcoded(tmp_path):
|
||||
# comma pins "a600000.ssusb"; we derive it, so another board still resolves
|
||||
assert link_controller(_mkudc(tmp_path)) == "a600000.ssusb"
|
||||
assert link_controller(_mkudc(tmp_path / "other", "a800000.dwc3")) == "a800000.ssusb"
|
||||
|
||||
|
||||
def test_link_controller_absent_udc_is_empty(tmp_path):
|
||||
assert link_controller(tmp_path / "nope") == ""
|
||||
|
||||
|
||||
def test_usb3_lane_mapping():
|
||||
assert usb3_lane(1) == "a"
|
||||
assert usb3_lane(2) == "b"
|
||||
assert usb3_lane(0) == "unknown" # unattached
|
||||
assert usb3_lane(None if False else 7) == "unknown"
|
||||
|
||||
|
||||
def test_port_lane_survives_gadget_mode(tmp_path):
|
||||
"""The case upstream cannot report: in peripheral mode nothing enumerates on
|
||||
the link controller, so every Device row is 'unknown' while the eMac link is
|
||||
up. The port-level field still carries it."""
|
||||
ctrl = _mkctrl(tmp_path, name="a800000.ssusb")
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678, ctrl=ctrl) # panda, host controller
|
||||
_mkudc(tmp_path) # gadget is a600000
|
||||
|
||||
devices = get_usb_state(tmp_path / "bus", tmp_path / "udc")
|
||||
assert all(d["usb3Lane"] == "unknown" for d in devices), "no device sits on the gadget controller"
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, devices, 0, lane="b")
|
||||
assert msg.deviceState.usbState.usb3Lane == "b"
|
||||
assert all(d.usb3Lane == "unknown" for d in msg.deviceState.usbState.devices)
|
||||
|
||||
|
||||
def test_device_on_link_controller_gets_the_lane(tmp_path):
|
||||
# host mode on the type-C port (eGPU dock): upstream's per-device field populates
|
||||
ctrl = _mkctrl(tmp_path, name="a600000.ssusb")
|
||||
_mkdev(tmp_path, "1-1", vid=EGPU_DOCK_USB_IDS[0][0], pid=EGPU_DOCK_USB_IDS[0][1], ctrl=ctrl)
|
||||
_mkudc(tmp_path)
|
||||
import iqpilot.system.hardware.usb as usbmod
|
||||
orig = usbmod.usb3_lane
|
||||
usbmod.usb3_lane = lambda orientation=None: "a"
|
||||
try:
|
||||
devices = get_usb_state(tmp_path / "bus", tmp_path / "udc")
|
||||
finally:
|
||||
usbmod.usb3_lane = orig
|
||||
assert devices[0]["usb3Lane"] == "a"
|
||||
|
||||
|
||||
def test_dock_ready_requires_the_bundled_firmware_product(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product=EGPU_DOCK_FW_PRODUCT)
|
||||
assert egpu_dock_present(root / "bus")
|
||||
assert egpu_dock_ready(root / "bus")
|
||||
|
||||
|
||||
def test_dock_on_foreign_firmware_is_present_but_not_ready(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product="custom deadbeef-CLEAN")
|
||||
assert egpu_dock_present(root / "bus")
|
||||
assert not egpu_dock_ready(root / "bus")
|
||||
|
||||
|
||||
def test_rom_mode_dock_is_neither_present_nor_ready(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_ROM_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product="USB 3.2 PCIe TinyEnclosure")
|
||||
assert not egpu_dock_present(root / "bus")
|
||||
assert not egpu_dock_ready(root / "bus")
|
||||
@@ -78,32 +78,38 @@ unbind() {
|
||||
fi
|
||||
}
|
||||
|
||||
set_attr() {
|
||||
[ "$(cat "$1" 2>/dev/null)" = "$2" ] && return 0
|
||||
echo "$2" | sudo tee "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_base() {
|
||||
if ! mountpoint -q /config; then
|
||||
sudo mount -t configfs none /config
|
||||
fi
|
||||
sudo mkdir -p "$GADGET/strings/0x409" "$GADGET/configs/c.1/strings/0x409"
|
||||
cd "$GADGET"
|
||||
[ -s idVendor ] || echo 0x04D8 | sudo tee idVendor >/dev/null
|
||||
[ -s idProduct ] || echo 0x1235 | sudo tee idProduct >/dev/null
|
||||
[ -s strings/0x409/serialnumber ] || echo "$(cat /proc/cmdline | sed -e 's/^.*androidboot.serialno=//' -e 's/ .*$//')" | sudo tee strings/0x409/serialnumber >/dev/null
|
||||
[ -s strings/0x409/manufacturer ] || echo "comma.ai" | sudo tee strings/0x409/manufacturer >/dev/null
|
||||
[ -s strings/0x409/product ] || echo "IQ.Pilot" | sudo tee strings/0x409/product >/dev/null
|
||||
[ -s configs/c.1/MaxPower ] || echo 250 | sudo tee configs/c.1/MaxPower >/dev/null
|
||||
[ -s configs/c.1/strings/0x409/configuration ] || echo "IQ.Pilot" | sudo tee configs/c.1/strings/0x409/configuration >/dev/null
|
||||
# `[ -s ]` never guards a configfs attribute: an unset idVendor still reads back
|
||||
# as "0x0000", so those writes were all skipped and the gadget stayed nameless
|
||||
set_attr idVendor 0x04D8
|
||||
set_attr idProduct 0x1235
|
||||
set_attr strings/0x409/serialnumber "$(sed -e 's/^.*androidboot.serialno=//' -e 's/ .*$//' /proc/cmdline)"
|
||||
set_attr strings/0x409/manufacturer "comma.ai"
|
||||
set_attr strings/0x409/product "IQ.Pilot"
|
||||
set_attr configs/c.1/MaxPower 250
|
||||
set_attr configs/c.1/strings/0x409/configuration "IQ.Pilot"
|
||||
}
|
||||
|
||||
add_adb() {
|
||||
# same rationale as add_mass_storage: start from a clean slate to avoid stale busy attributes
|
||||
remove_adb
|
||||
cd "$GADGET"
|
||||
sudo mkdir -p functions/ncm.0 functions/ffs.adb
|
||||
sudo mkdir -p functions/ffs.adb
|
||||
sudo mkdir -p /dev/usb-ffs/adb
|
||||
if ! mountpoint -q /dev/usb-ffs/adb; then
|
||||
sudo mount -t functionfs adb /dev/usb-ffs/adb
|
||||
fi
|
||||
sudo rm -f configs/c.1/ncm.0 configs/c.1/ffs.adb
|
||||
sudo ln -s functions/ncm.0 configs/c.1/
|
||||
sudo rm -f configs/c.1/ffs.adb
|
||||
sudo ln -s functions/ffs.adb configs/c.1/
|
||||
setprop service.adb.tcp.port -1 2>/dev/null || true
|
||||
sudo systemctl start adbd
|
||||
@@ -115,9 +121,42 @@ remove_adb() {
|
||||
sudo systemctl stop adbd || true
|
||||
if [ -d "$GADGET" ]; then
|
||||
cd "$GADGET"
|
||||
sudo rm -f configs/c.1/ncm.0 configs/c.1/ffs.adb
|
||||
sudo rm -f configs/c.1/ffs.adb
|
||||
sudo umount /dev/usb-ffs/adb 2>/dev/null || true
|
||||
sudo rmdir functions/ncm.0 functions/ffs.adb 2>/dev/null || true
|
||||
sudo rmdir functions/ffs.adb 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ncm carries the usb0 ethernet link. ADB needs it, but so does the Mac-backed
|
||||
# model worker with ADB off, so it is enabled independently of either.
|
||||
# the kernel randomises the ncm MACs every boot, so macOS sees a new adapter each
|
||||
# time and orphans the network service holding the link's static address
|
||||
ncm_id() {
|
||||
local id
|
||||
id=$(tr -dc '0-9a-f' < /data/params/d/DongleId 2>/dev/null | tail -c 6)
|
||||
[ ${#id} -eq 6 ] || id="000001"
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
add_ncm() {
|
||||
remove_ncm
|
||||
cd "$GADGET"
|
||||
sudo mkdir -p functions/ncm.0
|
||||
local id
|
||||
id=$(ncm_id)
|
||||
# best effort: some kernels create the ncm netdev lazily and fail these writes
|
||||
# with ENODEV, and a pinned MAC is never worth losing the whole gadget over
|
||||
echo "02:49:51:${id:0:2}:${id:2:2}:${id:4:2}" | sudo tee functions/ncm.0/host_addr >/dev/null 2>&1 || true
|
||||
echo "06:49:51:${id:0:2}:${id:2:2}:${id:4:2}" | sudo tee functions/ncm.0/dev_addr >/dev/null 2>&1 || true
|
||||
sudo rm -f configs/c.1/ncm.0
|
||||
sudo ln -s functions/ncm.0 configs/c.1/
|
||||
}
|
||||
|
||||
remove_ncm() {
|
||||
if [ -d "$GADGET" ]; then
|
||||
cd "$GADGET"
|
||||
sudo rm -f configs/c.1/ncm.0
|
||||
sudo rmdir functions/ncm.0 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -162,10 +201,18 @@ USB_STORAGE_ENABLE=0
|
||||
read_bool_param "/data/params/d/UsbStorageEnabled" && USB_STORAGE_ENABLE=1
|
||||
ADB_ENABLE=0
|
||||
read_bool_param "/data/params/d/AdbEnabled" && ADB_ENABLE=1
|
||||
EMAC_ENABLE=0
|
||||
read_bool_param "/data/params/d/IQEmacEnabled" && EMAC_ENABLE=1
|
||||
|
||||
unbind
|
||||
ensure_base
|
||||
|
||||
if [ "$ADB_ENABLE" == "1" ] || [ "$EMAC_ENABLE" == "1" ]; then
|
||||
add_ncm
|
||||
else
|
||||
remove_ncm
|
||||
fi
|
||||
|
||||
if [ "$ADB_ENABLE" == "1" ]; then
|
||||
add_adb
|
||||
else
|
||||
|
||||
@@ -15,3 +15,57 @@ def apply_usb_storage_state(state: bool):
|
||||
subprocess.Popen(args)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
NCM_TRIED_MARKER = "/tmp/.iqemac_ncm_provisioned"
|
||||
MAX_NCM_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _ncm_attempts() -> int:
|
||||
try:
|
||||
with open(NCM_TRIED_MARKER) as f:
|
||||
return int(f.read().strip() or 0)
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_ncm_gadget() -> bool:
|
||||
# configfs is RAM backed, so the gadget must be rebuilt every boot; binding it
|
||||
# enumerates on the host, which must not happen mid-drive
|
||||
if os.path.isdir("/sys/class/net/usb0"):
|
||||
return True
|
||||
params = Params()
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
if not (params.get_bool("IQEmacEnabled") or egpu_selected(params)):
|
||||
return False
|
||||
attempts = _ncm_attempts()
|
||||
if attempts >= MAX_NCM_ATTEMPTS:
|
||||
return False
|
||||
try:
|
||||
# stamped before the run: the gadget build can wedge configfs in
|
||||
# uninterruptible D state, and retrying that forever helps nobody
|
||||
with open(NCM_TRIED_MARKER, "w") as f:
|
||||
f.write(str(attempts + 1))
|
||||
subprocess.run(["sudo", "-n", SCRIPT_PATH], timeout=120, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return os.path.isdir("/sys/class/net/usb0")
|
||||
|
||||
|
||||
INPUT_SUSPEND = "/sys/class/power_supply/battery/input_suspend"
|
||||
|
||||
|
||||
def suspend_usb_input(suspend: bool = True) -> bool:
|
||||
# a host on the data port makes the PMIC sink USB-PD while OBD-C feeds the same
|
||||
# rail; the SOM browns out. This closes the charge path only, data is untouched
|
||||
if not os.path.exists(INPUT_SUSPEND):
|
||||
return False
|
||||
try:
|
||||
with open(INPUT_SUSPEND) as f:
|
||||
if f.read().strip() == ("1" if suspend else "0"):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
rc = subprocess.run(["sudo", "-n", "sh", "-c", f"echo {int(suspend)} > {INPUT_SUSPEND}"],
|
||||
check=False, capture_output=True)
|
||||
return rc.returncode == 0
|
||||
|
||||
184
iqpilot/system/hardware/usb.py
Normal file
184
iqpilot/system/hardware/usb.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
USB bus snapshot for deviceState: every enumerated device with its negotiated
|
||||
speed and its controller's link-error count. Landing this in every rlog makes
|
||||
cable/hub/link regressions diagnosable from a recorded route instead of only
|
||||
live.
|
||||
|
||||
Link errors come from `portli` on the ssusb controller (IQ.OS 4.9.1+); on older
|
||||
builds the file is absent and the counts read 0.
|
||||
|
||||
The USB eGPU dock is identified by VID/PID only. comma's internal codename for
|
||||
it is deliberately not used here: IQ.Pilot runs these models on several
|
||||
backends (eGPU dock, eMac), so the naming stays about the role, not the vendor.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# comma's USB eGPU dock, both shipped USB IDs. The ROM ids are the same board
|
||||
# sitting in its bootloader (ASMedia) before vendor firmware is flashed — it
|
||||
# enumerates but cannot serve a GPU in that state.
|
||||
EGPU_DOCK_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
|
||||
EGPU_DOCK_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
|
||||
# must equal image_product() of the bundled firmware; test_egpu_dock_flash pins them together
|
||||
EGPU_DOCK_FW_PRODUCT = "custom ed4e39b7-CLEAN"
|
||||
|
||||
|
||||
def is_egpu_usb_device(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool:
|
||||
ids = EGPU_DOCK_USB_IDS + EGPU_DOCK_ROM_USB_IDS if include_bootloader else EGPU_DOCK_USB_IDS
|
||||
return (vendor_id, product_id) in ids
|
||||
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
|
||||
UDC_PATH = Path("/sys/class/udc")
|
||||
TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation")
|
||||
USB3_LANES = {1: "a", 2: "b"} # 0 = unattached
|
||||
SOC_PLATFORM_PATH = Path("/sys/devices/platform/soc")
|
||||
CONTROLLER_SUFFIX = ".ssusb"
|
||||
LINK_ERRORS_FILE = "portli"
|
||||
|
||||
|
||||
def read(path: Path) -> str | None:
|
||||
# a controller in peripheral mode fails portli's show(); that surfaces as TypeError, not OSError
|
||||
try:
|
||||
return path.read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_int(path: Path, base: int = 10) -> int:
|
||||
try:
|
||||
return int(path.read_text(), base)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def read_hex_counter(path: Path) -> int:
|
||||
"""sysfs counter printed as '0x0000002a' (portli), tolerating a bare hex value."""
|
||||
raw = read(path)
|
||||
if raw is None:
|
||||
return 0
|
||||
try:
|
||||
return int(raw, 0) if raw.lower().startswith("0x") else int(raw, 16)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_usb_topology(root: Path = USB_DEVICES_PATH) -> set[str]:
|
||||
"""Names of everything on the bus; a cheap way to detect hotplug without
|
||||
re-reading every attribute."""
|
||||
try:
|
||||
return {p.name for p in root.iterdir()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def usb_devices(root: Path = USB_DEVICES_PATH) -> list[Path]:
|
||||
try:
|
||||
return sorted((d for d in root.glob("*") if (d / "idVendor").exists()), key=lambda p: p.name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def controller(device: Path) -> Path | None:
|
||||
"""The SuperSpeed controller a device hangs off (…/a800000.ssusb)."""
|
||||
try:
|
||||
return next((p for p in device.resolve().parents if p.name.endswith(CONTROLLER_SUFFIX)), None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def usb_controllers(soc: Path = SOC_PLATFORM_PATH) -> list[Path]:
|
||||
try:
|
||||
return sorted(soc.glob(f"*{CONTROLLER_SUFFIX}"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def link_controller(udc_root: Path = UDC_PATH) -> str:
|
||||
"""Name of the Type-C port's controller, derived from the UDC rather than
|
||||
hardcoded: the gadget exposes `<addr>.dwc3`, whose address prefix is the
|
||||
`<addr>.ssusb` controller behind the same connector. comma pins the 3X value
|
||||
directly, which would be wrong on any other board."""
|
||||
try:
|
||||
udc = next(iter(sorted(p.name for p in udc_root.iterdir())), "")
|
||||
except Exception:
|
||||
return ""
|
||||
return f"{udc.split('.')[0]}{CONTROLLER_SUFFIX}" if udc else ""
|
||||
|
||||
|
||||
def usb3_lane(orientation: int | None = None) -> str:
|
||||
"""Which SuperSpeed lane the Type-C connector landed on. Unattached reads 0,
|
||||
which is 'unknown' rather than a lane."""
|
||||
if orientation is None:
|
||||
orientation = read_int(TYPEC_CC_ORIENTATION_PATH)
|
||||
return USB3_LANES.get(orientation, "unknown")
|
||||
|
||||
|
||||
def link_errors(ctrl: Path | None) -> int:
|
||||
return read_hex_counter(ctrl / LINK_ERRORS_FILE) if ctrl is not None else 0
|
||||
|
||||
|
||||
def get_link_error_count(soc: Path = SOC_PLATFORM_PATH) -> int:
|
||||
"""Cumulative SS port link errors, read off the controller rather than a
|
||||
device: in peripheral mode (eMac gadget link) the peer never enumerates on
|
||||
our side, so there is no device row to carry the count."""
|
||||
return sum(link_errors(c) for c in usb_controllers(soc))
|
||||
|
||||
|
||||
def egpu_dock_present(root: Path = USB_DEVICES_PATH) -> bool:
|
||||
"""A dock in ROM/bootloader state is deliberately NOT counted as present: it
|
||||
enumerates but cannot serve a GPU until vendor firmware is flashed."""
|
||||
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
|
||||
for d in usb_devices(root))
|
||||
|
||||
|
||||
def egpu_dock_ready(root: Path = USB_DEVICES_PATH) -> bool:
|
||||
"""Present AND running the exact firmware we ship. A dock on any other
|
||||
firmware enumerates fine but has not been validated with this stack, so the
|
||||
runtime refuses it; the flasher still sees it via egpu_dock_present."""
|
||||
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
|
||||
and (read(d / "product") or "").strip() == EGPU_DOCK_FW_PRODUCT
|
||||
for d in usb_devices(root))
|
||||
|
||||
|
||||
def get_usb_state(root: Path = USB_DEVICES_PATH, udc_root: Path = UDC_PATH) -> list[dict]:
|
||||
devices = []
|
||||
lane, link_ctrl = usb3_lane(), link_controller(udc_root)
|
||||
for device in usb_devices(root):
|
||||
ctrl = controller(device)
|
||||
devices.append({
|
||||
"usb3Lane": lane if ctrl is not None and ctrl.name == link_ctrl else "unknown",
|
||||
"busnum": read_int(device / "busnum"),
|
||||
"devnum": read_int(device / "devnum"),
|
||||
"vendorId": read_int(device / "idVendor", 16),
|
||||
"productId": read_int(device / "idProduct", 16),
|
||||
"speedMbps": read_int(device / "speed"),
|
||||
"manufacturer": read(device / "manufacturer") or "",
|
||||
"product": read(device / "product") or "",
|
||||
# 16-bit field upstream, so mask rather than let a wrapped counter overflow it
|
||||
"linkErrorCount": link_errors(ctrl) & 0xFFFF,
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def set_usb_state(device_state, devices: list[dict], link_error_count: int = 0,
|
||||
lane: str | None = None) -> None:
|
||||
entries = device_state.usbState.init('devices', len(devices))
|
||||
|
||||
dock_present = False
|
||||
for entry, device in zip(entries, devices, strict=True):
|
||||
entry.busnum = device["busnum"]
|
||||
entry.devnum = device["devnum"]
|
||||
entry.vendorId = device["vendorId"]
|
||||
entry.productId = device["productId"]
|
||||
entry.speedMbps = device["speedMbps"]
|
||||
entry.manufacturer = device["manufacturer"]
|
||||
entry.product = device["product"]
|
||||
entry.linkErrorCount = device.get("linkErrorCount", 0) & 0xFFFF
|
||||
entry.usb3Lane = device.get("usb3Lane", "unknown")
|
||||
|
||||
if (entry.vendorId, entry.productId) in EGPU_DOCK_USB_IDS:
|
||||
dock_present = True
|
||||
|
||||
device_state.usbState.linkErrorCount = link_error_count
|
||||
device_state.usbState.usb3Lane = lane if lane is not None else usb3_lane()
|
||||
device_state.egpuDockPresent = dock_present
|
||||
Reference in New Issue
Block a user