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

View File

@@ -0,0 +1,22 @@
import os
from typing import cast
from iqpilot.system.hardware.base import HardwareBase
from iqpilot.system.hardware.tici.hardware import Tici
from iqpilot.system.hardware.pc.hardware import Pc
TICI = os.path.isfile('/TICI')
AGNOS = os.path.isfile('/AGNOS')
PC = not TICI
if TICI:
HARDWARE = cast(HardwareBase, Tici())
else:
HARDWARE = cast(HardwareBase, Pc())
# Only comma 3/3X expose the DMA-BUF EGL extensions used by the zero-copy
# camera renderer and the direct EGL frame-pacing calls. /TICI is also present
# on comma 4, so it identifies the AGNOS hardware family rather than this GPU
# capability.
EGL_DMA_BUF_SUPPORTED = TICI and HARDWARE.get_device_type() in ("tici", "tizi")

View File

@@ -0,0 +1,228 @@
import os
from abc import abstractmethod, ABC
from dataclasses import dataclass, fields
from iqpilot.cereal import log
NetworkType = log.DeviceState.NetworkType
NetworkStrength = log.DeviceState.NetworkStrength
class LPAError(RuntimeError):
pass
class LPAProfileNotFoundError(LPAError):
pass
@dataclass
class Profile:
iccid: str
nickname: str
enabled: bool
provider: str
@dataclass
class ThermalZone:
# a zone from /sys/class/thermal/thermal_zone*
name: str # a.k.a type
scale: float = 1000. # scale to get degrees in C
zone_number = -1
def read(self) -> float:
if self.zone_number < 0:
for n in os.listdir("/sys/devices/virtual/thermal"):
if not n.startswith("thermal_zone"):
continue
with open(os.path.join("/sys/devices/virtual/thermal", n, "type")) as f:
if f.read().strip() == self.name:
self.zone_number = int(n.removeprefix("thermal_zone"))
break
try:
with open(f"/sys/devices/virtual/thermal/thermal_zone{self.zone_number}/temp") as f:
return int(f.read()) / self.scale
except FileNotFoundError:
return 0
@dataclass
class ThermalConfig:
cpu: list[ThermalZone] | None = None
gpu: list[ThermalZone] | None = None
dsp: ThermalZone | None = None
pmic: list[ThermalZone] | None = None
memory: ThermalZone | None = None
intake: ThermalZone | None = None
exhaust: ThermalZone | None = None
case: ThermalZone | None = None
def get_msg(self):
ret = {}
for f in fields(ThermalConfig):
v = getattr(self, f.name)
if v is not None:
if isinstance(v, list):
ret[f.name + "TempC"] = [x.read() for x in v]
else:
ret[f.name + "TempC"] = v.read()
return ret
class LPABase(ABC):
@abstractmethod
def list_profiles(self) -> list[Profile]:
pass
@abstractmethod
def get_active_profile(self) -> Profile | None:
pass
@abstractmethod
def delete_profile(self, iccid: str) -> None:
pass
@abstractmethod
def bootstrap(self) -> None:
pass
@abstractmethod
def download_profile(self, qr: str, nickname: str | None = None) -> None:
pass
@abstractmethod
def nickname_profile(self, iccid: str, nickname: str) -> None:
pass
@abstractmethod
def switch_profile(self, iccid: str) -> None:
pass
def is_comma_profile(self, iccid: str) -> bool:
return any(iccid.startswith(prefix) for prefix in ('8985235',))
class HardwareBase(ABC):
@staticmethod
def get_cmdline() -> dict[str, str]:
with open('/proc/cmdline') as f:
cmdline = f.read()
return {kv[0]: kv[1] for kv in [s.split('=') for s in cmdline.split(' ')] if len(kv) == 2}
@staticmethod
def read_param_file(path, parser, default=0):
try:
with open(path) as f:
return parser(f.read())
except Exception:
return default
def booted(self) -> bool:
return True
def reboot(self, reason=None):
print("REBOOT!")
def uninstall(self):
print("uninstall")
def get_os_version(self):
return None
@abstractmethod
def get_device_type(self):
pass
def get_imei(self, slot) -> str:
return ""
def get_serial(self):
return ""
def get_network_info(self):
return None
def get_network_type(self):
return NetworkType.none
def get_sim_info(self):
return {
'sim_id': '',
'mcc_mnc': None,
'network_type': ["Unknown"],
'sim_state': ["ABSENT"],
'data_connected': False
}
def get_sim_lpa(self) -> LPABase:
raise NotImplementedError("SIM LPA not available")
def get_network_strength(self, network_type):
return NetworkStrength.unknown
def get_network_metered(self, network_type) -> bool:
return network_type not in (NetworkType.none, NetworkType.wifi, NetworkType.ethernet)
def get_current_power_draw(self):
return 0
def get_som_power_draw(self):
return 0
def shutdown(self):
print("SHUTDOWN!")
def get_thermal_config(self):
return ThermalConfig()
def set_display_power(self, on: bool):
pass
def set_screen_brightness(self, percentage):
pass
def get_screen_brightness(self):
return 0
def set_power_save(self, powersave_enabled):
pass
def get_gpu_usage_percent(self):
return 0
def get_modem_version(self):
return None
def get_modem_temperatures(self):
return []
def initialize_hardware(self):
pass
def configure_modem(self):
pass
def reboot_modem(self):
pass
def recover_sim_detection(self) -> bool:
return False
def get_networks(self):
return None
def has_internal_panda(self) -> bool:
return False
def reset_internal_panda(self):
pass
def recover_internal_panda(self):
pass
def get_modem_data_usage(self):
return -1, -1
def get_voltage(self) -> float:
return 0.
def get_current(self) -> float:
return 0.
def set_ir_power(self, percent: int):
pass

View 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.

View 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())

Binary file not shown.

View 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)

View 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

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import numpy as np
class FanController:
def update(self, cur_temp: float, ignition: bool, max_cool: bool = False) -> int:
if max_cool:
return 100
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)
return fan_pwr_out

View File

@@ -0,0 +1,749 @@
#!/usr/bin/env python3
import fcntl
import os
import subprocess
import sys
import queue
import struct
import threading
import time
from collections import OrderedDict, namedtuple
import psutil
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import car
from iqpilot.cereal import log
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
from iqpilot.system.hardware.fan_controller import FanController
from iqpilot.system.version import terms_version, training_version, get_build_metadata
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
NetworkStrength = log.DeviceState.NetworkStrength
CURRENT_TAU = 15. # 15s time constant
TEMP_TAU = 5. # 5s time constant
DISCONNECT_TIMEOUT = 5. # wait 5 seconds before going offroad after disconnect so you get an alert
PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) # 1.5x the expected pandaState frequency
ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle
CAN_STARTUP_RECOVERY_DELAY = 3. # require a persistent CAN timeout before cycling onroad processes
CAN_STARTUP_RECOVERY_WINDOW = 30. # only recover shortly after ignition turns on
CAN_STARTUP_RECOVERY_COOLDOWN = 5. # allow the restarted car stack time to initialize
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', '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.
THERMAL_BANDS = OrderedDict({
ThermalStatus.green: ThermalBand(None, 80.0),
ThermalStatus.yellow: ThermalBand(75.0, 96.0),
ThermalStatus.red: ThermalBand(88.0, 107.),
ThermalStatus.danger: ThermalBand(94.0, None),
})
# Override to highest thermal band when offroad and above this temp
OFFROAD_DANGER_TEMP = 75
prev_offroad_states: dict[str, tuple[bool, str | None]] = {}
ALLOWED_TICI_BRANCHES = {"release-new", "release-tici", "master-mici", "beta", "beta-pq", "release-prebuilt"}
class CanStartupRecovery:
"""Bounded recovery for a car stack that starts without a usable CAN stream."""
def __init__(self) -> None:
self.ignition_on_ts: float | None = None
self.timeout_started_ts: float | None = None
self.last_attempt_ts: float | None = None
self.attempts = 0
def update(self, now: float, ignition: bool, started: bool, engaged: bool,
car_state_alive: bool, can_timeout: bool, v_ego: float) -> bool:
if not ignition:
self.ignition_on_ts = None
self.timeout_started_ts = None
self.last_attempt_ts = None
self.attempts = 0
return False
if self.ignition_on_ts is None:
self.ignition_on_ts = now
eligible = (
started
and not engaged
and car_state_alive
and can_timeout
and abs(v_ego) < 0.1
and (now - self.ignition_on_ts) <= CAN_STARTUP_RECOVERY_WINDOW
and self.attempts < CAN_STARTUP_RECOVERY_MAX_ATTEMPTS
and (self.last_attempt_ts is None or (now - self.last_attempt_ts) >= CAN_STARTUP_RECOVERY_COOLDOWN)
)
if not eligible:
self.timeout_started_ts = None
return False
if self.timeout_started_ts is None:
self.timeout_started_ts = now
return False
if (now - self.timeout_started_ts) < CAN_STARTUP_RECOVERY_DELAY:
return False
self.attempts += 1
self.last_attempt_ts = now
self.timeout_started_ts = None
return True
def get_top_memory_processes(limit: int = 5) -> list[dict[str, object]]:
procs: list[dict[str, object]] = []
for proc in psutil.process_iter(['pid', 'name', 'memory_info', 'memory_percent']):
try:
info = proc.info
rss = int(getattr(info.get('memory_info'), 'rss', 0))
procs.append({
"pid": int(info.get('pid', -1)),
"name": str(info.get('name', 'unknown')),
"rss_mb": round(rss / (1024 * 1024), 1),
"mem_pct": round(float(info.get('memory_percent') or 0.0), 2),
})
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, TypeError, ValueError):
continue
procs.sort(key=lambda p: p["rss_mb"], reverse=True)
return procs[:limit]
def _is_meb(CP) -> bool:
try:
if CP.brand != "volkswagen":
return False
from iqdbc.car.volkswagen.values import VolkswagenFlags
return bool(CP.flags & VolkswagenFlags.MEB)
except Exception:
cloudlog.exception("MEB detection failed")
return False
class _CarParamsCache:
def __init__(self, refresh_s: float = 5.0):
self._refresh_s = refresh_s
self._last_check = 0.0
self._last_bytes: bytes | None = None
self.no_sleep = False
def update(self, params: Params) -> None:
now = time.monotonic()
if (now - self._last_check) < self._refresh_s:
return
self._last_check = now
cp_bytes = params.get("CarParams")
if not cp_bytes or cp_bytes == self._last_bytes:
return
self._last_bytes = cp_bytes
try:
CP = messaging.log_from_bytes(cp_bytes, car.CarParams)
self.no_sleep = (CP.brand == "tesla") or _is_meb(CP)
except Exception:
self.no_sleep = False
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
prev_offroad_states[offroad_alert] = (show_alert, extra_text)
set_offroad_alert(offroad_alert, show_alert, extra_text)
def is_supported_tici_branch(build_metadata) -> bool:
return build_metadata.channel_type == "tici" or build_metadata.channel in ALLOWED_TICI_BRANCHES
def touch_thread(end_event):
count = 0
pm = messaging.PubMaster(["touch"])
event_format = "llHHi"
event_size = struct.calcsize(event_format)
event_frame = []
with open("/dev/input/by-path/platform-894000.i2c-event", "rb") as event_file:
fcntl.fcntl(event_file, fcntl.F_SETFL, os.O_NONBLOCK)
while not end_event.is_set():
if (count % int(1. / DT_HW)) == 0:
event = event_file.read(event_size)
if event:
(sec, usec, etype, code, value) = struct.unpack(event_format, event)
if etype != 0 or code != 0 or value != 0:
touch = log.Touch.new_message()
touch.sec = sec
touch.usec = usec
touch.type = etype
touch.code = code
touch.value = value
event_frame.append(touch)
else: # end of frame, push new log
msg = messaging.new_message('touch', len(event_frame), valid=True)
msg.touch = event_frame
pm.send('touch', msg)
event_frame = []
continue
count += 1
time.sleep(DT_HW)
def hw_state_thread(end_event, hw_queue):
"""Handles non critical hardware state, and sends over queue"""
count = 0
prev_hw_state = None
modem_version = None
modem_configured = False
modem_missing_count = 0
modem_restart_count = 0
sim_detection_recovered = False
while not end_event.is_set():
# these are expensive calls. update every 10s
if (count % int(10. / DT_HW)) == 0:
try:
network_type = HARDWARE.get_network_type()
modem_temps = HARDWARE.get_modem_temperatures()
if len(modem_temps) == 0 and prev_hw_state is not None:
modem_temps = prev_hw_state.modem_temps
# Log modem version once
if AGNOS and (modem_version is None):
modem_version = HARDWARE.get_modem_version()
if modem_version is not None:
cloudlog.event("modem version", version=modem_version)
if AGNOS and modem_restart_count < 3 and HARDWARE.get_modem_version() is None:
# TODO: we may be able to remove this with a MM update
# ModemManager's probing on startup can fail
# rarely, restart the service to probe again.
# Also, AT commands sometimes timeout resulting in ModemManager not
# trying to use this modem anymore.
modem_missing_count += 1
if (modem_missing_count % 4) == 0:
modem_restart_count += 1
cloudlog.event("restarting ModemManager")
os.system("sudo systemctl restart --no-block ModemManager")
tx, rx = HARDWARE.get_modem_data_usage()
hw_state = HardwareState(
network_type=network_type,
network_info=HARDWARE.get_network_info(),
network_strength=HARDWARE.get_network_strength(network_type),
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:
hw_queue.put_nowait(hw_state)
except queue.Full:
pass
if not modem_configured and HARDWARE.get_modem_version() is not None:
cloudlog.warning("configuring modem")
HARDWARE.configure_modem()
modem_configured = True
if modem_configured and not sim_detection_recovered and HARDWARE.recover_sim_detection():
cloudlog.event("sim missing with hot-swap detect armed, rebooting modem with detect disabled", error=True)
sim_detection_recovered = True
prev_hw_state = hw_state
except Exception:
cloudlog.exception("Error getting hardware state")
count += 1
time.sleep(DT_HW)
def hardware_thread(end_event, hw_queue) -> None:
pm = messaging.PubMaster(['deviceState', 'iqPerfTrace'])
sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "carState", "egpuDockState"], poll="pandaStates")
perf = PerfTraceEmitter("hardwared", pubmaster=pm)
count = 0
onroad_conditions: dict[str, bool] = {
"ignition": False,
"not_onroad_cycle": True,
"device_temp_good": True,
}
startup_conditions: dict[str, bool] = {}
startup_conditions_prev: dict[str, bool] = {}
off_ts: float | None = None
started_ts: float | None = None
started_seen = False
startup_blocked_ts: float | None = None
thermal_status = ThermalStatus.yellow
last_hw_state = HardwareState(
network_type=NetworkType.none,
network_info=None,
network_metered=False,
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)
offroad_temp_filter = FirstOrderFilter(0., TEMP_TAU, DT_HW, initialized=False)
low_memory_logged = False
should_start_prev = False
in_car = False
engaged_prev = False
pwrsave = False
low_power = False
low_power_prev = False
offroad_cycle_count = 0
can_startup_recovery = CanStartupRecovery()
params = Params()
power_monitor = PowerMonitoring()
cp_cache = _CarParamsCache()
uptime_offroad: float = params.get("UptimeOffroad", return_default=True)
uptime_onroad: float = params.get("UptimeOnroad", return_default=True)
last_uptime_ts: float = time.monotonic()
HARDWARE.initialize_hardware()
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)
pandaStates = sm['pandaStates']
peripheralState = sm['peripheralState']
# handle requests to cycle system started state
if params.get_bool("OnroadCycleRequested"):
params.put_bool("OnroadCycleRequested", False)
offroad_cycle_count = sm.frame
car_state = sm['carState']
if can_startup_recovery.update(
time.monotonic(),
ignition=onroad_conditions["ignition"],
started=started_ts is not None,
engaged=sm['selfdriveState'].enabled,
car_state_alive=sm.alive['carState'],
can_timeout=car_state.canTimeout,
v_ego=car_state.vEgo,
):
offroad_cycle_count = sm.frame
cloudlog.event("automatic CAN startup recovery", attempt=can_startup_recovery.attempts, error=True)
onroad_conditions["not_onroad_cycle"] = (sm.frame - offroad_cycle_count) >= ONROAD_CYCLE_TIME * SERVICE_LIST['pandaStates'].frequency
if sm.updated['pandaStates'] and len(pandaStates) > 0:
# Set ignition based on any panda connected
onroad_conditions["ignition"] = any(ps.ignitionLine or ps.ignitionCan for ps in pandaStates if ps.pandaType != log.PandaState.PandaType.unknown)
if params.get_bool("IQBenchIgnition"):
onroad_conditions["ignition"] = True
pandaState = pandaStates[0]
in_car = pandaState.harnessStatus != log.PandaState.HarnessStatus.notConnected
elif (time.monotonic() - sm.recv_time['pandaStates']) > DISCONNECT_TIMEOUT:
if onroad_conditions["ignition"]:
onroad_conditions["ignition"] = False
cloudlog.error("panda timed out onroad")
# Run at 2Hz, plus either edge of ignition
ign_edge = (started_ts is not None) != all(onroad_conditions.values())
if (sm.frame % round(SERVICE_LIST['pandaStates'].frequency * DT_HW) != 0) and not ign_edge:
continue
msg = messaging.new_message('deviceState', valid=True)
msg.deviceState = thermal_config.get_msg()
msg.deviceState.deviceType = HARDWARE.get_device_type()
try:
last_hw_state = hw_queue.get_nowait()
except queue.Empty:
pass
msg.deviceState.freeSpacePercent = get_available_percent(default=100.0)
try:
msg.deviceState.memoryUsagePercent = int(round(psutil.virtual_memory().percent))
except Exception:
msg.deviceState.memoryUsagePercent = 0
# get_top_memory_processes() costs ~500ms: must never run in the 2Hz publish loop
if msg.deviceState.memoryUsagePercent > 95:
if not low_memory_logged:
cloudlog.event("low_memory_snapshot", memory_usage_percent=msg.deviceState.memoryUsagePercent,
top_processes=get_top_memory_processes(), error=True)
low_memory_logged = True
else:
low_memory_logged = False
msg.deviceState.gpuUsagePercent = int(round(HARDWARE.get_gpu_usage_percent()))
online_cpu_usage = [int(round(n)) for n in psutil.cpu_percent(percpu=True)]
offline_cpu_usage = [0., ] * (len(msg.deviceState.cpuTempC) - len(online_cpu_usage))
msg.deviceState.cpuUsagePercent = online_cpu_usage + offline_cpu_usage
if msg.deviceState.memoryUsagePercent > 85:
avg_cpu_usage = int(round(sum(online_cpu_usage) / max(1, len(online_cpu_usage))))
perf.emit(
"hardware_low_memory",
severity="error" if msg.deviceState.memoryUsagePercent > 95 else "warning",
frame_id=sm.frame,
samples=[PerfSample(
frame_id=sm.frame,
memory_usage_percent=int(msg.deviceState.memoryUsagePercent),
gpu_usage_percent=int(msg.deviceState.gpuUsagePercent),
cpu_usage_percent=avg_cpu_usage,
)],
detail=f"memory_usage_percent={msg.deviceState.memoryUsagePercent} gpu_usage_percent={msg.deviceState.gpuUsagePercent}",
min_interval_s=5.0,
)
msg.deviceState.networkType = last_hw_state.network_type
msg.deviceState.networkMetered = last_hw_state.network_metered
msg.deviceState.networkStrength = last_hw_state.network_strength
msg.deviceState.networkStats = last_hw_state.network_stats
if last_hw_state.network_info is not 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("UsbGpuReady"),
sm["egpuDockState"] if egpu_valid else None, set_offroad_alert_if_changed)
msg.deviceState.screenBrightnessPercent = HARDWARE.get_screen_brightness()
# this subset is only used for offroad
temp_sources = [
msg.deviceState.memoryTempC,
max(msg.deviceState.cpuTempC, default=0.),
max(msg.deviceState.gpuTempC, default=0.),
]
offroad_comp_temp = offroad_temp_filter.update(max(temp_sources))
# this drives the thermal status while onroad
temp_sources.append(max(msg.deviceState.pmicTempC, default=0.))
all_comp_temp = all_temp_filter.update(max(temp_sources))
msg.deviceState.maxTempC = all_comp_temp
is_offroad_for_5_min = (started_ts is None) and ((not started_seen) or (off_ts is None) or (time.monotonic() - off_ts > 60 * 5))
if is_offroad_for_5_min and offroad_comp_temp > OFFROAD_DANGER_TEMP:
# if device is offroad and already hot without the extra onroad load,
# we want to cool down first before increasing load
thermal_status = ThermalStatus.danger
else:
current_band = THERMAL_BANDS[thermal_status]
band_idx = list(THERMAL_BANDS.keys()).index(thermal_status)
if current_band.min_temp is not None and all_comp_temp < current_band.min_temp:
thermal_status = list(THERMAL_BANDS.keys())[band_idx - 1]
elif current_band.max_temp is not None and all_comp_temp > current_band.max_temp:
thermal_status = list(THERMAL_BANDS.keys())[band_idx + 1]
# the car is running but temperature is blocking the start, so cool as fast as we can
max_cool = (started_ts is None) and onroad_conditions["ignition"] and thermal_status >= ThermalStatus.red
msg.deviceState.fanSpeedPercentDesired = fan_controller.update(all_comp_temp, onroad_conditions["ignition"], max_cool)
# **** starting logic ****
startup_conditions["up_to_date"] = True
startup_conditions["no_excessive_actuation"] = params.get("Offroad_ExcessiveActuation") is None
startup_conditions["not_uninstalling"] = not params.get_bool("DoUninstall")
startup_conditions["accepted_terms"] = params.get("HasAcceptedTerms") == terms_version
# with 2% left, we killall, otherwise the phone will take a long time to boot
startup_conditions["free_space"] = msg.deviceState.freeSpacePercent > 2
startup_conditions["completed_training"] = HARDWARE.get_device_type() != "mici" or params.get("CompletedTrainingVersion") == training_version
startup_conditions["not_driver_view"] = not params.get_bool("IsDriverViewEnabled")
startup_conditions["not_taking_snapshot"] = not params.get_bool("IsTakingSnapshot")
# must be at an engageable thermal band to go onroad
startup_conditions["device_temp_engageable"] = thermal_status < ThermalStatus.red
# ensure device is fully booted
startup_conditions["device_booted"] = startup_conditions.get("device_booted", False) or HARDWARE.booted()
# user-forced status (Always Offroad can be temporarily overridden)
offroad_mode = params.get_bool("IQAlwaysOffroad")
force_onroad_until = params.get("ForceOnroadUntil", return_default=True)
now = int(time.time())
force_onroad_active = offroad_mode and force_onroad_until > now
if force_onroad_until > 0 and (not offroad_mode or force_onroad_until <= now):
params.put("ForceOnroadUntil", 0)
startup_conditions["not_always_offroad"] = (not offroad_mode) or force_onroad_active
onroad_conditions["not_always_offroad"] = (not offroad_mode) or force_onroad_active
# if an unsupported device and branch is detected, going onroad is blocked
# only allow going onroad when:
# - TIZI, or
# - TICI and channel_type is "tici"
build_metadata = get_build_metadata()
is_unsupported_combo = TICI and HARDWARE.get_device_type() == "tici" and not is_supported_tici_branch(build_metadata)
startup_conditions["not_tici"] = not is_unsupported_combo
onroad_conditions["not_tici"] = not is_unsupported_combo
set_offroad_alert("Offroad_TiciSupport", is_unsupported_combo, extra_text=build_metadata.channel)
# if the temperature enters the danger zone, go offroad to cool down
onroad_conditions["device_temp_good"] = thermal_status < ThermalStatus.danger
extra_text = f"{offroad_comp_temp:.1f}C"
show_alert = (not onroad_conditions["device_temp_good"] or not startup_conditions["device_temp_engageable"]) and onroad_conditions["ignition"]
set_offroad_alert_if_changed("Offroad_TemperatureTooHigh", show_alert, extra_text=extra_text)
# Handle offroad/onroad transition
should_start = all(onroad_conditions.values())
if started_ts is None:
should_start = should_start and all(startup_conditions.values())
if should_start != should_start_prev or (count == 0):
params.put_bool("IsEngaged", False)
engaged_prev = False
if sm.updated['selfdriveState']:
engaged = sm['selfdriveState'].enabled
if engaged != engaged_prev:
params.put_bool("IsEngaged", engaged)
engaged_prev = engaged
try:
with open('/dev/kmsg', 'w') as kmsg:
kmsg.write(f"<3>[hardware] engaged: {engaged}\n")
except Exception:
pass
cp_cache.update(params)
no_sleep = cp_cache.no_sleep
should_pwrsave = (not no_sleep) and (not onroad_conditions["ignition"] and msg.deviceState.screenBrightnessPercent < 1e-3)
if should_pwrsave != pwrsave or (count == 0):
HARDWARE.set_power_save(should_pwrsave)
pwrsave = should_pwrsave
if should_start:
off_ts = None
if started_ts is None:
started_ts = time.monotonic()
started_seen = True
if startup_blocked_ts is not None:
cloudlog.event("Startup after block", block_duration=(time.monotonic() - startup_blocked_ts),
startup_conditions=startup_conditions, onroad_conditions=onroad_conditions,
startup_conditions_prev=startup_conditions_prev, error=True)
startup_blocked_ts = None
else:
if onroad_conditions["ignition"] and (startup_conditions != startup_conditions_prev):
cloudlog.event("Startup blocked", startup_conditions=startup_conditions, onroad_conditions=onroad_conditions, error=True)
startup_conditions_prev = startup_conditions.copy()
startup_blocked_ts = time.monotonic()
started_ts = None
if off_ts is None:
off_ts = time.monotonic()
# Offroad power monitoring
voltage = None if peripheralState.pandaType == log.PandaState.PandaType.unknown else peripheralState.voltage
power_monitor.calculate(voltage, onroad_conditions["ignition"])
msg.deviceState.offroadPowerUsageUwh = power_monitor.get_power_used()
msg.deviceState.carBatteryCapacityUwh = max(0, power_monitor.get_car_battery_capacity())
current_power_draw = HARDWARE.get_current_power_draw()
msg.deviceState.powerDrawW = current_power_draw
som_power_draw = HARDWARE.get_som_power_draw()
msg.deviceState.somPowerDrawW = som_power_draw
# FastSleep deep standby: shed heavy processes once parked with the screen idled off
# (or at low battery) instead of shutting down, recover on ignition or once the
# alternator is charging
fast_sleep = params.get_bool("FastSleep")
if fast_sleep and not no_sleep:
if low_power:
if onroad_conditions["ignition"] or power_monitor.car_voltage_mV >= (VBATT_LOW_POWER_EXIT * 1e3):
low_power = False
else:
screen_off = msg.deviceState.screenBrightnessPercent < 1e-3
low_power = power_monitor.should_enter_low_power(onroad_conditions["ignition"], in_car, off_ts, screen_off)
else:
low_power = False
# Blank the panel only in deep standby, where not_low_power has shed the UI (so nothing
# relights it and there is no touch grab to fight). The parked idle screen-off and
# tap-to-wake live in the UI, which owns the touchscreen grab and brightness.
if low_power != low_power_prev:
params.put("DevicePowerState", "low_power" if low_power else "normal")
cloudlog.event("hardwared.device_power_state", low_power=low_power, voltage_mV=power_monitor.car_voltage_mV, error=False)
if low_power:
HARDWARE.set_screen_brightness(0)
low_power_prev = low_power
# Check if we need to shut down
if (not no_sleep) and power_monitor.should_shutdown(onroad_conditions["ignition"], in_car, off_ts, started_seen):
cloudlog.warning(f"shutting device down, offroad since {off_ts}")
params.put_bool("DoShutdown", True)
msg.deviceState.started = started_ts is not None and not offroad_mode
msg.deviceState.startedMonoTime = int(1e9*(started_ts or 0))
last_ping = params.get("LastAthenaPingTime")
if last_ping is not None:
msg.deviceState.lastAthenaPingTime = last_ping
msg.deviceState.thermalStatus = thermal_status
pm.send("deviceState", msg)
# report to server once every 10 minutes
rising_edge_started = should_start and not should_start_prev
if rising_edge_started or (count % int(600. / DT_HW)) == 0:
dat = {
'count': count,
'pandaStates': [strip_deprecated_keys(p.to_dict()) for p in pandaStates],
'peripheralState': strip_deprecated_keys(peripheralState.to_dict()),
'location': (strip_deprecated_keys(sm["gpsLocationExternal"].to_dict()) if sm.alive["gpsLocationExternal"] else None),
'deviceState': strip_deprecated_keys(msg.to_dict())
}
cloudlog.event("STATUS_PACKET", **dat)
# save last one before going onroad
if rising_edge_started:
try:
params.put("LastOffroadStatusPacket", dat)
except Exception:
cloudlog.exception("failed to save offroad status")
params.put_bool_nonblocking("NetworkMetered", msg.deviceState.networkMetered)
now_ts = time.monotonic()
if off_ts:
uptime_offroad += now_ts - max(last_uptime_ts, off_ts)
elif started_ts:
uptime_onroad += now_ts - max(last_uptime_ts, started_ts)
last_uptime_ts = now_ts
if (count % int(60. / DT_HW)) == 0:
params.put("UptimeOffroad", uptime_offroad)
params.put("UptimeOnroad", uptime_onroad)
count += 1
should_start_prev = should_start
def main():
hw_queue = queue.Queue(maxsize=1)
end_event = threading.Event()
threads = [
threading.Thread(target=hw_state_thread, args=(end_event, hw_queue)),
threading.Thread(target=hardware_thread, args=(end_event, hw_queue)),
]
if TICI:
threads.append(threading.Thread(target=touch_thread, args=(end_event,)))
for t in threads:
t.start()
try:
while True:
time.sleep(1)
if not all(t.is_alive() for t in threads):
break
finally:
end_event.set()
for t in threads:
t.join()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,137 @@
import os
import platform
from pathlib import Path
from iqpilot.system.hardware import PC
DEFAULT_DOWNLOAD_CACHE_ROOT = "/tmp/comma_download_cache"
class Paths:
_persist_root_cache: str | None = None
@staticmethod
def _is_writable_persist_root(path: str) -> bool:
try:
os.makedirs(path, exist_ok=True)
comma_dir = os.path.join(path, "comma")
os.makedirs(comma_dir, exist_ok=True)
probe_path = os.path.join(comma_dir, ".rw_probe")
with open(probe_path, "w") as f:
f.write("1")
os.remove(probe_path)
return True
except OSError:
return False
@staticmethod
def comma_home() -> str:
return os.path.join(str(Path.home()), ".comma" + os.environ.get("OPENPILOT_PREFIX", ""))
@staticmethod
def params() -> str:
if os.environ.get("PARAMS_ROOT"):
return os.environ["PARAMS_ROOT"]
return os.path.join(Paths.comma_home(), "params") if PC else "/data/params"
@staticmethod
def log_root() -> str:
if os.environ.get('LOG_ROOT', False):
return os.environ['LOG_ROOT']
elif PC:
return str(Path(Paths.comma_home()) / "media" / "0" / "realdata")
else:
return '/data/media/0/realdata/'
@staticmethod
def log_root_external() -> str:
return '/mnt/external_realdata/'
@staticmethod
def swaglog_root() -> str:
if PC:
return os.path.join(Paths.comma_home(), "log")
else:
return "/data/log/"
@staticmethod
def swaglog_ipc() -> str:
return "ipc:///tmp/logmessage" + os.environ.get("OPENPILOT_PREFIX", "")
@staticmethod
def download_cache_root() -> str:
if os.environ.get('COMMA_CACHE', False):
return os.environ['COMMA_CACHE'] + "/"
return DEFAULT_DOWNLOAD_CACHE_ROOT + os.environ.get("OPENPILOT_PREFIX", "") + "/"
@staticmethod
def persist_root() -> str:
if PC:
return os.path.join(Paths.comma_home(), "persist")
if Paths._persist_root_cache is not None:
return Paths._persist_root_cache
for candidate in ("/persist", "/data/persist"):
if Paths._is_writable_persist_root(candidate):
Paths._persist_root_cache = candidate
return candidate
# Keep previous behavior as a last resort.
Paths._persist_root_cache = "/persist"
return Paths._persist_root_cache
@staticmethod
def stats_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "stats")
else:
return "/data/stats/"
@staticmethod
def stats_iq_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "stats")
else:
return "/data/stats_iq/"
@staticmethod
def config_root() -> str:
if PC:
return Paths.comma_home()
else:
return "/tmp/.comma"
@staticmethod
def shm_path() -> str:
if PC and platform.system() == "Darwin":
return "/tmp" # This is not really shared memory on macOS, but it's the closest we can get
return "/dev/shm"
@staticmethod
def model_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "media" / "0" / "models")
else:
return "/data/media/0/models"
@staticmethod
def crash_log_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "community" / "crashes")
else:
return "/data/community/crashes"
@staticmethod
def mapd_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "media" / "0" / "osm")
else:
return "/data/media/0/osm"
@staticmethod
def screen_recordings_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "media" / "0" / "screen_recordings")
else:
return "/data/media/0/screen_recordings"

View File

View File

@@ -0,0 +1,12 @@
from iqpilot.cereal import log
from iqpilot.system.hardware.base import HardwareBase
NetworkType = log.DeviceState.NetworkType
class Pc(HardwareBase):
def get_device_type(self):
return "pc"
def get_network_type(self):
return NetworkType.wifi

View File

@@ -0,0 +1,165 @@
import time
import threading
from iqpilot.common.params import Params
from iqpilot.system.hardware import HARDWARE
from iqpilot.common.swaglog import cloudlog
CAR_VOLTAGE_LOW_PASS_K = 0.011 # LPF gain for 45s tau (dt/tau / (dt/tau + 1))
# While driving, a battery charges completely in about 30-60 minutes
CAR_BATTERY_CAPACITY_uWh = 30e6
CAR_CHARGING_RATE_W = 45
VBATT_PAUSE_CHARGING = 11.8 # Lower limit on the LPF car battery voltage
# FastSleep (deep standby): enter low power once parked with the screen idled off, or
# immediately at the normal shutdown voltage; shut down at a lower floor, exit once the
# alternator is charging
VBATT_LOW_POWER_ENTRY = 11.8
VBATT_LOW_POWER_EXIT = 12.8
VBATT_HARD_SHUTDOWN = 11.5
LOW_POWER_ENTRY_TIME_S = 300
MAX_TIME_OFFROAD_S = 30*3600
MIN_ON_TIME_S = 3600
DELAY_SHUTDOWN_TIME_S = 300 # Wait at least DELAY_SHUTDOWN_TIME_S seconds after offroad_time to shutdown.
VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S = 60
class PowerMonitoring:
def __init__(self):
self.params = Params()
self.last_measurement_time = None # Used for integration delta
self.last_save_time = 0 # Used for saving current value in a param
self.power_used_uWh = 0 # Integrated power usage in uWh since going into offroad
self.next_pulsed_measurement_time = None
self.car_voltage_mV = 12e3 # Low-passed version of peripheralState voltage
self.car_voltage_instant_mV = 12e3 # Last value of peripheralState voltage
self.integration_lock = threading.Lock()
car_battery_capacity_uWh = self.params.get("CarBatteryCapacity") or 0
# Reset capacity if it's low
self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh)
# Calculation tick
def calculate(self, voltage: int | None, ignition: bool):
try:
now = time.monotonic()
# If peripheralState is None, we're probably not in a car, so we don't care
if voltage is None:
with self.integration_lock:
self.last_measurement_time = None
self.next_pulsed_measurement_time = None
self.power_used_uWh = 0
return
# Low-pass battery voltage
self.car_voltage_instant_mV = voltage
self.car_voltage_mV = ((voltage * CAR_VOLTAGE_LOW_PASS_K) + (self.car_voltage_mV * (1 - CAR_VOLTAGE_LOW_PASS_K)))
# Cap the car battery power and save it in a param every 10-ish seconds
self.car_battery_capacity_uWh = max(self.car_battery_capacity_uWh, 0)
self.car_battery_capacity_uWh = min(self.car_battery_capacity_uWh, CAR_BATTERY_CAPACITY_uWh)
if now - self.last_save_time >= 10:
self.params.put_nonblocking("CarBatteryCapacity", int(self.car_battery_capacity_uWh))
self.last_save_time = now
# First measurement, set integration time
with self.integration_lock:
if self.last_measurement_time is None:
self.last_measurement_time = now
return
if ignition:
# If there is ignition, we integrate the charging rate of the car
with self.integration_lock:
self.power_used_uWh = 0
integration_time_h = (now - self.last_measurement_time) / 3600
if integration_time_h < 0:
raise ValueError(f"Negative integration time: {integration_time_h}h")
self.car_battery_capacity_uWh += (CAR_CHARGING_RATE_W * 1e6 * integration_time_h)
self.last_measurement_time = now
else:
# Get current power draw somehow
current_power = HARDWARE.get_current_power_draw()
# Do the integration
self._perform_integration(now, current_power)
except Exception:
cloudlog.exception("Power monitoring calculation failed")
def _perform_integration(self, t: float, current_power: float) -> None:
with self.integration_lock:
try:
if self.last_measurement_time:
integration_time_h = (t - self.last_measurement_time) / 3600
power_used = (current_power * 1000000) * integration_time_h
if power_used < 0:
raise ValueError(f"Negative power used! Integration time: {integration_time_h} h Current Power: {power_used} uWh")
self.power_used_uWh += power_used
self.car_battery_capacity_uWh -= power_used
self.last_measurement_time = t
except Exception:
cloudlog.exception("Integration failed")
# Get the power usage
def get_power_used(self) -> int:
return int(self.power_used_uWh)
def get_car_battery_capacity(self) -> int:
return int(self.car_battery_capacity_uWh)
# Max Time Offroad
def max_time_offroad_exceeded(self, offroad_time):
"""
Check if the max time offroad has been exceeded. If the value is 0, it means no limit.
:param offroad_time: Time spent offroad in seconds
:return: True if the max time offroad has been exceeded, False otherwise
"""
try:
param = self.params.get("MaxTimeOffroad")
iq_max_time_val_s = param * 60 if param is not None and param >= 0 else MAX_TIME_OFFROAD_S
except Exception:
iq_max_time_val_s = MAX_TIME_OFFROAD_S
return 0 < iq_max_time_val_s <= offroad_time
# FastSleep: see if we should enter low power mode instead of shutting down
def should_enter_low_power(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, screen_off: bool) -> bool:
if offroad_timestamp is None or ignition or not in_car:
return False
if not self.params.get_bool("FastSleep"):
return False
offroad_time = time.monotonic() - offroad_timestamp
# a healthy battery rests above VBATT_LOW_POWER_ENTRY, so parked entry must be
# time-based; the voltage trigger stays as the sagging-battery fast path
low_voltage = (self.car_voltage_mV < (VBATT_LOW_POWER_ENTRY * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
parked_idle = screen_off and offroad_time > LOW_POWER_ENTRY_TIME_S
return low_voltage or parked_idle
# See if we need to shutdown
def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool):
if offroad_timestamp is None:
return False
now = time.monotonic()
should_shutdown = False
offroad_time = (now - offroad_timestamp)
fast_sleep = self.params.get_bool("FastSleep")
vbatt_min = VBATT_HARD_SHUTDOWN if fast_sleep else VBATT_PAUSE_CHARGING
low_voltage_shutdown = (self.car_voltage_mV < (vbatt_min * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
should_shutdown |= self.max_time_offroad_exceeded(offroad_time)
should_shutdown |= low_voltage_shutdown
# the 30 Wh bookkeeping model empties within hours at offroad draw regardless of the
# real battery state; under FastSleep the measured voltage floors govern instead
should_shutdown |= (self.car_battery_capacity_uWh <= 0) and not fast_sleep
should_shutdown &= not ignition
should_shutdown &= (not self.params.get_bool("DisablePowerDown"))
should_shutdown &= in_car
should_shutdown &= offroad_time > DELAY_SHUTDOWN_TIME_S
should_shutdown |= self.params.get_bool("ForcePowerDown")
should_shutdown &= started_seen or (now > MIN_ON_TIME_S)
return should_shutdown

View 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

View File

@@ -0,0 +1,41 @@
import numpy as np
from iqpilot.system.hardware.fan_controller import FanController
class TestFanController:
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 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
def test_hot_onroad(self):
assert FanController().update(100, True) >= 70
def test_offroad_capped(self):
c = FanController()
for t in (60, 75, 85, 100):
assert c.update(t, False) <= 30
def test_no_fan_wear(self):
assert FanController().update(10, False) == 0
def test_max_cool(self):
c = FanController()
assert c.update(80, True, True) == 100
assert c.update(80, False, True) == 100
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

View File

@@ -0,0 +1,75 @@
from types import SimpleNamespace
from iqpilot.system.hardware.hardwared import (
ALLOWED_TICI_BRANCHES,
CAN_STARTUP_RECOVERY_COOLDOWN,
CAN_STARTUP_RECOVERY_DELAY,
CAN_STARTUP_RECOVERY_MAX_ATTEMPTS,
CanStartupRecovery,
is_supported_tici_branch,
)
def test_beta_pq_allowed_for_tici():
metadata = SimpleNamespace(channel="beta-pq", channel_type="dev")
assert "beta-pq" in ALLOWED_TICI_BRANCHES
assert is_supported_tici_branch(metadata)
def test_tici_channel_type_allowed():
metadata = SimpleNamespace(channel="random-branch", channel_type="tici")
assert is_supported_tici_branch(metadata)
def test_unsupported_branch_rejected_for_tici():
metadata = SimpleNamespace(channel="random-branch", channel_type="dev")
assert not is_supported_tici_branch(metadata)
def recovery_update(recovery: CanStartupRecovery, now: float, **kwargs) -> bool:
defaults = {
"ignition": True,
"started": True,
"engaged": False,
"car_state_alive": True,
"can_timeout": True,
"v_ego": 0.,
}
return recovery.update(now, **(defaults | kwargs))
def test_can_startup_recovery_requires_persistent_timeout():
recovery = CanStartupRecovery()
assert not recovery_update(recovery, 10.)
assert not recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY - 0.1)
assert recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY)
def test_can_startup_recovery_only_when_safe():
for unsafe_state in (
{"started": False},
{"engaged": True},
{"car_state_alive": False},
{"can_timeout": False},
{"v_ego": 0.2},
):
recovery = CanStartupRecovery()
assert not recovery_update(recovery, 10., **unsafe_state)
assert not recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY, **unsafe_state)
def test_can_startup_recovery_is_bounded_and_resets_next_ignition():
recovery = CanStartupRecovery()
now = 10.
for _ in range(CAN_STARTUP_RECOVERY_MAX_ATTEMPTS):
assert not recovery_update(recovery, now)
now += CAN_STARTUP_RECOVERY_DELAY
assert recovery_update(recovery, now)
now += CAN_STARTUP_RECOVERY_COOLDOWN
assert not recovery_update(recovery, now)
assert not recovery_update(recovery, now + CAN_STARTUP_RECOVERY_DELAY)
assert not recovery_update(recovery, now + 10., ignition=False)
assert not recovery_update(recovery, now + 11.)
assert recovery_update(recovery, now + 11. + CAN_STARTUP_RECOVERY_DELAY)

View File

@@ -0,0 +1,323 @@
import pytest
from iqpilot.common.params import Params
from iqpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \
CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S, \
VBATT_HARD_SHUTDOWN, LOW_POWER_ENTRY_TIME_S
# Create fake time
ssb = 0.
def mock_time_monotonic():
global ssb
ssb += 1.
return ssb
TEST_DURATION_S = 50
GOOD_VOLTAGE = 12 * 1e3
VOLTAGE_BELOW_PAUSE_CHARGING = (VBATT_PAUSE_CHARGING - 1) * 1e3
def pm_patch(mocker, name, value, constant=False):
if constant:
mocker.patch(f"iqpilot.system.hardware.power_monitoring.{name}", value)
else:
mocker.patch(f"iqpilot.system.hardware.power_monitoring.{name}", return_value=value)
@pytest.fixture(autouse=True)
def mock_time(mocker):
mocker.patch("time.monotonic", mock_time_monotonic)
class TestPowerMonitoring:
def setup_method(self):
self.params = Params()
# Test to see that it doesn't do anything when pandaState is None
def test_panda_state_present(self):
pm = PowerMonitoring()
for _ in range(10):
pm.calculate(None, None)
assert pm.get_power_used() == 0
assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10)
# Test to see that it doesn't integrate offroad when ignition is True
def test_offroad_ignition(self):
pm = PowerMonitoring()
for _ in range(10):
pm.calculate(GOOD_VOLTAGE, True)
assert pm.get_power_used() == 0
# Test to see that it integrates with discharging battery
def test_offroad_integration_discharging(self, mocker):
POWER_DRAW = 4
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
for _ in range(TEST_DURATION_S + 1):
pm.calculate(GOOD_VOLTAGE, False)
expected_power_usage = ((TEST_DURATION_S/3600) * POWER_DRAW * 1e6)
assert abs(pm.get_power_used() - expected_power_usage) < 10
# Test to check positive integration of car_battery_capacity
def test_car_battery_integration_onroad(self, mocker):
POWER_DRAW = 4
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 0
for _ in range(TEST_DURATION_S + 1):
pm.calculate(GOOD_VOLTAGE, True)
expected_capacity = ((TEST_DURATION_S/3600) * CAR_CHARGING_RATE_W * 1e6)
assert abs(pm.get_car_battery_capacity() - expected_capacity) < 10
# Test to check positive integration upper limit
def test_car_battery_integration_upper_limit(self, mocker):
POWER_DRAW = 4
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh - 1000
for _ in range(TEST_DURATION_S + 1):
pm.calculate(GOOD_VOLTAGE, True)
estimated_capacity = CAR_BATTERY_CAPACITY_uWh + (CAR_CHARGING_RATE_W / 3600 * 1e6)
assert abs(pm.get_car_battery_capacity() - estimated_capacity) < 10
# Test to check negative integration of car_battery_capacity
def test_car_battery_integration_offroad(self, mocker):
POWER_DRAW = 4
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
for _ in range(TEST_DURATION_S + 1):
pm.calculate(GOOD_VOLTAGE, False)
expected_capacity = CAR_BATTERY_CAPACITY_uWh - ((TEST_DURATION_S/3600) * POWER_DRAW * 1e6)
assert abs(pm.get_car_battery_capacity() - expected_capacity) < 10
# Test to check negative integration lower limit
def test_car_battery_integration_lower_limit(self, mocker):
POWER_DRAW = 4
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 1000
for _ in range(TEST_DURATION_S + 1):
pm.calculate(GOOD_VOLTAGE, False)
estimated_capacity = 0 - ((1/3600) * POWER_DRAW * 1e6)
assert abs(pm.get_car_battery_capacity() - estimated_capacity) < 10
# Test to check policy of stopping charging after MAX_TIME_OFFROAD_S
def test_max_time_offroad(self, mocker):
MOCKED_MAX_OFFROAD_TIME = 3600
POWER_DRAW = 0 # To stop shutting down for other reasons
pm_patch(mocker, "MAX_TIME_OFFROAD_S", MOCKED_MAX_OFFROAD_TIME, constant=True)
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
start_time = ssb
ignition = False
while ssb <= start_time + MOCKED_MAX_OFFROAD_TIME:
pm.calculate(GOOD_VOLTAGE, ignition)
if (ssb - start_time) % 1000 == 0 and ssb < start_time + MOCKED_MAX_OFFROAD_TIME:
assert not pm.should_shutdown(ignition, True, start_time, False)
assert pm.should_shutdown(ignition, True, start_time, False)
def test_car_voltage(self, mocker):
POWER_DRAW = 0 # To stop shutting down for other reasons
TEST_TIME = 350
VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S = 50
pm_patch(mocker, "VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S", VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S, constant=True)
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
ignition = False
start_time = ssb
for i in range(TEST_TIME):
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
if i % 10 == 0:
assert pm.should_shutdown(ignition, True, start_time, True) == \
(pm.car_voltage_mV < VBATT_PAUSE_CHARGING * 1e3 and \
(ssb - start_time) > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S and \
(ssb - start_time) > DELAY_SHUTDOWN_TIME_S)
assert pm.should_shutdown(ignition, True, start_time, True)
# Test to check policy of not stopping charging when DisablePowerDown is set
def test_disable_power_down(self, mocker):
POWER_DRAW = 0 # To stop shutting down for other reasons
TEST_TIME = 100
self.params.put_bool("DisablePowerDown", True)
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
ignition = False
for i in range(TEST_TIME):
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
if i % 10 == 0:
assert not pm.should_shutdown(ignition, True, ssb, False)
assert not pm.should_shutdown(ignition, True, ssb, False)
# Test to check policy of not stopping charging when ignition
def test_ignition(self, mocker):
POWER_DRAW = 0 # To stop shutting down for other reasons
TEST_TIME = 100
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
ignition = True
for i in range(TEST_TIME):
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
if i % 10 == 0:
assert not pm.should_shutdown(ignition, True, ssb, False)
assert not pm.should_shutdown(ignition, True, ssb, False)
# Test to check policy of not stopping charging when harness is not connected
def test_harness_connection(self, mocker):
POWER_DRAW = 0 # To stop shutting down for other reasons
TEST_TIME = 100
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
ignition = False
for i in range(TEST_TIME):
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
if i % 10 == 0:
assert not pm.should_shutdown(ignition, False, ssb, False)
assert not pm.should_shutdown(ignition, False, ssb, False)
def test_delay_shutdown_time(self):
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 0
ignition = False
in_car = True
offroad_timestamp = ssb
started_seen = True
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
while ssb < offroad_timestamp + DELAY_SHUTDOWN_TIME_S:
assert not pm.should_shutdown(ignition, in_car,
offroad_timestamp,
started_seen), \
f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time"
assert pm.should_shutdown(ignition, in_car,
offroad_timestamp,
started_seen), \
f"Should shutdown after {DELAY_SHUTDOWN_TIME_S} seconds offroad time"
@pytest.mark.parametrize(
"max_time_offroad, offroad_time_min, expected_result",
[
# No max time set fallback to default (30 hours)
(None, 0, False),
(None, MAX_TIME_OFFROAD_S + 1, True), # exceeds 30h (1800+ mins)
# Valid max time values (in minutes)
(60, 59, False), # under limit
(60, 120, True), # over limit
(10, 8, False), # under limit
(10, 11, True), # over limit
# Edge case: max time is zero → no limit enforced
(0, 0, False),
(0, 400, False),
# Invalid max time formats or negative values → fallback to 30 hours
(-100, 100, False), # should fallback to 30h
(-1, MAX_TIME_OFFROAD_S + 1, True), # should fallback to 30h, and exceed it
]
)
def test_max_time_offroad_exceeded(self, max_time_offroad, offroad_time_min, expected_result):
# Set the parameter if provided
if max_time_offroad is not None:
self.params.put("MaxTimeOffroad", max_time_offroad)
# Convert offroad time from minutes to seconds
offroad_time_s = offroad_time_min * 60
pm = PowerMonitoring()
result = pm.max_time_offroad_exceeded(offroad_time_s)
assert result == expected_result
# FastSleep must not shut down on the empty bookkeeping model while voltage is healthy
def test_fast_sleep_ignores_battery_capacity_model(self, mocker):
self.params.put_bool("FastSleep", True)
self.params.put("MaxTimeOffroad", 0)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 0
start_time = ssb
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
pm.calculate(GOOD_VOLTAGE, False)
assert not pm.should_shutdown(False, True, start_time, True)
finally:
self.params.put_bool("FastSleep", False)
# FastSleep still shuts down below the hard voltage floor
def test_fast_sleep_hard_voltage_floor(self, mocker):
self.params.put_bool("FastSleep", True)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
start_time = ssb
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
pm.calculate((VBATT_HARD_SHUTDOWN - 0.5) * 1e3, False)
assert pm.should_shutdown(False, True, start_time, True)
finally:
self.params.put_bool("FastSleep", False)
def test_fast_sleep_low_power_entry(self, mocker):
self.params.put_bool("FastSleep", True)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
# parked with the screen idled off: time-based entry at healthy voltage
pm = PowerMonitoring()
start_time = ssb
for _ in range(LOW_POWER_ENTRY_TIME_S + 10):
pm.calculate(GOOD_VOLTAGE, False)
assert pm.should_enter_low_power(False, True, start_time, screen_off=True)
assert not pm.should_enter_low_power(False, True, start_time, screen_off=False)
assert not pm.should_enter_low_power(True, True, start_time, screen_off=True)
assert not pm.should_enter_low_power(False, False, start_time, screen_off=True)
# sagging battery: voltage entry regardless of screen state
pm = PowerMonitoring()
start_time = ssb
for _ in range(100):
pm.calculate((VBATT_HARD_SHUTDOWN + 0.1) * 1e3, False)
assert pm.should_enter_low_power(False, True, start_time, screen_off=False)
self.params.put_bool("FastSleep", False)
assert not pm.should_enter_low_power(False, True, start_time, screen_off=True)
finally:
self.params.put_bool("FastSleep", False)
def test_negative_charging_interval_is_rejected(self, mocker):
exception = mocker.patch("iqpilot.system.hardware.power_monitoring.cloudlog.exception")
pm = PowerMonitoring()
pm.last_measurement_time = ssb + 100
capacity = pm.car_battery_capacity_uWh
pm.calculate(GOOD_VOLTAGE, True)
assert pm.car_battery_capacity_uWh == capacity
exception.assert_called_once_with("Power monitoring calculation failed")
def test_negative_discharge_interval_is_rejected(self, mocker):
exception = mocker.patch("iqpilot.system.hardware.power_monitoring.cloudlog.exception")
pm = PowerMonitoring()
pm.last_measurement_time = ssb + 100
capacity = pm.car_battery_capacity_uWh
pm._perform_integration(ssb, 4.0)
assert pm.car_battery_capacity_uWh == capacity
assert pm.power_used_uWh == 0
exception.assert_called_once_with("Integration failed")
def test_max_time_offroad_uses_default_when_params_fail(self):
class UnavailableParams:
def get(self, key):
raise RuntimeError(key)
pm = PowerMonitoring()
pm.params = UnavailableParams()
assert not pm.max_time_offroad_exceeded(MAX_TIME_OFFROAD_S - 1)
assert pm.max_time_offroad_exceeded(MAX_TIME_OFFROAD_S)
def test_shutdown_requires_offroad_timestamp(self):
assert not PowerMonitoring().should_shutdown(False, True, None, True)

View File

@@ -0,0 +1,279 @@
"""
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")
class TestEnsureHostRole:
def test_already_host_needs_no_write(self, tmp_path):
from iqpilot.system.hardware.usb import ensure_host_role
mode = tmp_path / "mode"
mode.write_text("host\n")
assert ensure_host_role(mode)
def test_missing_controller_is_false(self, tmp_path):
from iqpilot.system.hardware.usb import ensure_host_role
assert not ensure_host_role(tmp_path / "absent" / "mode")

View File

View File

@@ -0,0 +1,91 @@
[
{
"name": "xbl",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz",
"hash": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"hash_raw": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"size": 3282256,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "d47a08914d2376557b03f1231b7233508222c04b57d781f9daf77c63eab92c2e"
},
{
"name": "xbl_config",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz",
"hash": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"hash_raw": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"size": 98124,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "e7d04d9f040c9c040cdf013335d0b6d6e9346311458baeb2461b193e954f5f1c"
},
{
"name": "abl",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz",
"hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee",
"hash_raw": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee",
"size": 274432,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee"
},
{
"name": "aop",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz",
"hash": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"hash_raw": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"size": 184364,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3aa0a79149ec57f4bc8c38f7bbdf4f6630dd659e49a111ce6258d2d06a07c8e5"
},
{
"name": "devcfg",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz",
"hash": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"hash_raw": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"size": 40336,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3d7bb33588491a2a40091a7e1cf6cb65e6dd503f69b640aba484d723f1ad47e8"
},
{
"name": "splash",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/splash-993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2.img.xz",
"hash": "993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2",
"hash_raw": "993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2",
"size": 34226176,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2"
},
{
"name": "boot",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/boot-595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4.img.xz",
"hash": "595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4",
"hash_raw": "595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4",
"size": 18216960,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "b0ee082b2e63a49fcfd1ca2adfb49275e1bb567889cbb9985827fb1de50f943b"
},
{
"name": "system",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/system-44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa.img.xz",
"hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"hash_raw": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"size": 6291456000,
"sparse": false,
"full_check": false,
"has_ab": true,
"ondevice_hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"url_parts": 10
}
]

View File

@@ -0,0 +1 @@
nqwhbcjeRqyPEM2UWshbP4eCC8EZzDAptGOG0refjvhHlEh32UCAp2Vi/GEKCGOLC3peRW8dRUgwCOXwtEm8Cg==

View File

@@ -0,0 +1,433 @@
#!/usr/bin/env python3
import base64
import hashlib
import json
import lzma
import os
import struct
import subprocess
import time
from collections.abc import Generator
import sys
_VENV_PY = "/usr/local/venv/bin/python3"
if sys.executable != _VENV_PY and os.path.exists(_VENV_PY):
try:
import Crypto # noqa: F401
except ImportError:
os.execv(_VENV_PY, [_VENV_PY, os.path.abspath(__file__), *sys.argv[1:]])
import requests
import iqpilot.system.updated.casync.casync as casync
try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
except Exception as exc:
Ed25519PublicKey = None
_CRYPTO_IMPORT_ERROR = exc
else:
_CRYPTO_IMPORT_ERROR = None
SPARSE_CHUNK_FMT = struct.Struct('H2xI4x')
CAIBX_URL = "https://commadist.azureedge.net/agnosupdate/"
IQPILOT_MANIFEST_PUBLIC_KEY = bytes.fromhex("40ae3f81b77506ecc4982a1ca37ba1d6f8765d2ae510eae9039577206c3e5732")
AGNOS_MANIFEST_FILE = "system/hardware/tici/agnos.json"
LFS_POINTER_MAGIC = b"version https://git-lfs"
def _image_auth_module():
try:
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
return import_verified_module("iqpilot_updater_private", "iqpilot_private.updater.git_remote")
except Exception:
pass
try:
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
bundle_python = os.path.join(root, "artifacts", "iqpilot_updater_private", "python")
if os.path.isdir(bundle_python):
if bundle_python not in sys.path:
sys.path.insert(0, bundle_python)
import importlib
return importlib.import_module("iqpilot_private.updater.git_remote")
except Exception:
pass
return None
def _download_headers(url: str) -> dict:
mod = _image_auth_module()
if mod is not None:
try:
headers = mod.os_image_headers(url)
if headers:
return headers
except Exception:
pass
try:
from iqpilot.common.git_creds import get_credentials
creds = get_credentials()
if creds and all(creds) and "/iq.lvbs/iqos" in url.lower():
return {"Authorization": "Basic " + base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode()}
except Exception:
pass
return {}
def _open_image_response(url: str) -> requests.Response:
auth = _download_headers(url)
req = requests.get(url, stream=True, headers={'Accept-Encoding': None, **auth}, timeout=60)
req.raise_for_status()
if int(req.headers.get('content-length') or 0) >= 1024:
return req
body = req.content
if not body.startswith(LFS_POINTER_MAGIC):
raise requests.exceptions.InvalidURL(f"unexpected tiny response ({len(body)} bytes) for {url}")
meta = dict(line.split(" ", 1) for line in body.decode().strip().splitlines() if " " in line)
oid = meta["oid"].split(":", 1)[1]
size = int(meta["size"])
lfs_base = url.split("/raw/", 1)[0] + ".git/info/lfs"
req = requests.get(f"{lfs_base}/objects/{oid}", stream=True,
headers={'Accept-Encoding': None, 'Accept': 'application/vnd.git-lfs', **auth}, timeout=60)
if req.status_code == 200:
return req
batch = requests.post(f"{lfs_base}/objects/batch",
data=json.dumps({"operation": "download", "transfers": ["basic"],
"objects": [{"oid": oid, "size": size}]}),
headers={"Content-Type": "application/vnd.git-lfs+json",
"Accept": "application/vnd.git-lfs+json", **auth},
timeout=60)
batch.raise_for_status()
action = batch.json()["objects"][0]["actions"]["download"]
req = requests.get(action["href"], stream=True,
headers={'Accept-Encoding': None, **action.get("header", {})}, timeout=60)
req.raise_for_status()
return req
def verify_manifest_signature(manifest_path: str) -> None:
sig_path = f"{manifest_path}.sig"
if not os.path.exists(sig_path):
raise RuntimeError(f"missing AGNOS manifest signature: {sig_path}")
if Ed25519PublicKey is None:
raise RuntimeError(f"cryptography import failed: {_CRYPTO_IMPORT_ERROR}")
manifest_bytes = open(manifest_path, "rb").read()
signature = base64.b64decode(open(sig_path, "rb").read().strip())
digest = hashlib.sha256(manifest_bytes).digest()
public_key = Ed25519PublicKey.from_public_bytes(IQPILOT_MANIFEST_PUBLIC_KEY)
public_key.verify(signature, digest)
class _ChainedParts:
def __init__(self, urls: list[str]) -> None:
self.urls = urls
self.req: requests.Response | None = None
def raise_for_status(self) -> None:
if self.req is not None:
self.req.raise_for_status()
def iter_content(self, chunk_size: int) -> Generator[bytes, None, None]:
for u in self.urls:
self.req = _open_image_response(u)
yield from self.req.iter_content(chunk_size=chunk_size)
class StreamingDecompressor:
def __init__(self, url: str, parts: int = 0) -> None:
self.buf = b""
if parts > 1:
self.req = _ChainedParts([f"{url}.p{i:02d}" for i in range(parts)])
else:
self.req = _open_image_response(url)
self.it = self.req.iter_content(chunk_size=1024 * 1024)
self.decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_AUTO)
self.eof = False
self.sha256 = hashlib.sha256()
def read(self, length: int) -> bytes:
while len(self.buf) < length and not self.eof:
if self.decompressor.needs_input:
self.req.raise_for_status()
try:
compressed = next(self.it)
except StopIteration:
self.eof = True
break
else:
compressed = b''
self.buf += self.decompressor.decompress(compressed, max_length=length)
if self.decompressor.eof:
self.eof = True
break
result = self.buf[:length]
self.buf = self.buf[length:]
self.sha256.update(result)
return result
def unsparsify(f: StreamingDecompressor) -> Generator[bytes, None, None]:
magic = struct.unpack("I", f.read(4))[0]
assert(magic == 0xed26ff3a)
major = struct.unpack("H", f.read(2))[0]
minor = struct.unpack("H", f.read(2))[0]
assert(major == 1 and minor == 0)
f.read(2)
f.read(2)
block_sz = struct.unpack("I", f.read(4))[0]
f.read(4)
num_chunks = struct.unpack("I", f.read(4))[0]
f.read(4)
for _ in range(num_chunks):
chunk_type, out_blocks = SPARSE_CHUNK_FMT.unpack(f.read(12))
if chunk_type == 0xcac1:
yield f.read(out_blocks * block_sz)
elif chunk_type == 0xcac2:
filler = f.read(4) * (block_sz // 4)
for _ in range(out_blocks):
yield filler
elif chunk_type == 0xcac3:
yield b""
else:
raise Exception("Unhandled sparse chunk type")
def noop(f: StreamingDecompressor) -> Generator[bytes, None, None]:
while len(chunk := f.read(1024 * 1024)) > 0:
yield chunk
def get_target_slot_number() -> int:
current_slot = subprocess.check_output(["abctl", "--boot_slot"], encoding='utf-8').strip()
return 1 if current_slot == "_a" else 0
def slot_number_to_suffix(slot_number: int) -> str:
assert slot_number in (0, 1)
return '_a' if slot_number == 0 else '_b'
def get_partition_path(target_slot_number: int, partition: dict) -> str:
path = f"/dev/disk/by-partlabel/{partition['name']}"
if partition.get('has_ab', True):
path += slot_number_to_suffix(target_slot_number)
return path
def get_raw_hash(path: str, partition_size: int) -> str:
raw_hash = hashlib.sha256()
pos, chunk_size = 0, 1024 * 1024
with open(path, 'rb+') as out:
while pos < partition_size:
n = min(chunk_size, partition_size - pos)
raw_hash.update(out.read(n))
pos += n
return raw_hash.hexdigest().lower()
def verify_partition(target_slot_number: int, partition: dict[str, str | int], force_full_check: bool = False) -> bool:
full_check = partition['full_check'] or force_full_check
path = get_partition_path(target_slot_number, partition)
if not isinstance(partition['size'], int):
return False
partition_size: int = partition['size']
if not isinstance(partition['hash_raw'], str):
return False
partition_hash: str = partition['hash_raw']
if full_check:
return get_raw_hash(path, partition_size) == partition_hash.lower()
else:
with open(path, 'rb+') as out:
out.seek(partition_size)
return out.read(64) == partition_hash.lower().encode()
def clear_partition_hash(target_slot_number: int, partition: dict) -> None:
path = get_partition_path(target_slot_number, partition)
with open(path, 'wb+') as out:
partition_size = partition['size']
out.seek(partition_size)
out.write(b"\x00" * 64)
os.sync()
def extract_compressed_image(target_slot_number: int, partition: dict, cloudlog):
path = get_partition_path(target_slot_number, partition)
downloader = StreamingDecompressor(partition['url'], parts=int(partition.get('url_parts', 0)))
with open(path, 'wb+') as out:
last_p = 0
raw_hash = hashlib.sha256()
f = unsparsify if partition['sparse'] else noop
for chunk in f(downloader):
raw_hash.update(chunk)
out.write(chunk)
p = int(out.tell() / partition['size'] * 100)
if p != last_p:
last_p = p
print(f"Installing {partition['name']}: {p}", flush=True)
if raw_hash.hexdigest().lower() != partition['hash_raw'].lower():
raise Exception(f"Raw hash mismatch '{raw_hash.hexdigest().lower()}'")
if downloader.sha256.hexdigest().lower() != partition['hash'].lower():
raise Exception("Uncompressed hash mismatch")
if out.tell() != partition['size']:
raise Exception("Uncompressed size mismatch")
os.sync()
def extract_casync_image(target_slot_number: int, partition: dict, cloudlog):
path = get_partition_path(target_slot_number, partition)
seed_path = path[:-1] + ('b' if path[-1] == 'a' else 'a')
target = casync.parse_caibx(partition['casync_caibx'])
sources: list[tuple[str, casync.ChunkReader, casync.ChunkDict]] = []
try:
raw_hash = get_raw_hash(seed_path, partition['size'])
caibx_url = f"{CAIBX_URL}{partition['name']}-{raw_hash}.caibx"
try:
cloudlog.info(f"casync fetching {caibx_url}")
sources += [('seed', casync.FileChunkReader(seed_path), casync.build_chunk_dict(casync.parse_caibx(caibx_url)))]
except requests.RequestException:
cloudlog.error(f"casync failed to load {caibx_url}")
except Exception:
cloudlog.exception("casync failed to hash seed partition")
sources += [('target', casync.FileChunkReader(path), casync.build_chunk_dict(target))]
sources += [('remote', casync.RemoteChunkReader(partition['casync_store']), casync.build_chunk_dict(target))]
last_p = 0
def progress(cur):
nonlocal last_p
p = int(cur / partition['size'] * 100)
if p != last_p:
last_p = p
print(f"Installing {partition['name']}: {p}", flush=True)
stats = casync.extract(target, sources, path, progress)
cloudlog.error(f'casync done {json.dumps(stats)}')
os.sync()
if not verify_partition(target_slot_number, partition, force_full_check=True):
raise Exception(f"Raw hash mismatch '{partition['hash_raw'].lower()}'")
def flash_partition(target_slot_number: int, partition: dict, cloudlog, standalone=False):
cloudlog.info(f"Downloading and writing {partition['name']}")
if verify_partition(target_slot_number, partition):
cloudlog.info(f"Already flashed {partition['name']}")
return
full_check = partition['full_check']
if not full_check:
clear_partition_hash(target_slot_number, partition)
path = get_partition_path(target_slot_number, partition)
if ('casync_caibx' in partition) and not standalone:
extract_casync_image(target_slot_number, partition, cloudlog)
else:
extract_compressed_image(target_slot_number, partition, cloudlog)
if not full_check:
with open(path, 'wb+') as out:
out.seek(partition['size'])
out.write(partition['hash_raw'].lower().encode())
def swap(manifest_path: str, target_slot_number: int, cloudlog) -> None:
verify_manifest_signature(manifest_path)
update = json.load(open(manifest_path))
for partition in update:
if not partition.get('full_check', False):
clear_partition_hash(target_slot_number, partition)
while True:
out = subprocess.check_output(f"abctl --set_active {target_slot_number}", shell=True, stderr=subprocess.STDOUT, encoding='utf8')
if ("No such file or directory" not in out) and ("lun as boot lun" in out):
cloudlog.info(f"Swap successful {out}")
break
else:
cloudlog.error(f"Swap failed {out}")
def flash_agnos_update(manifest_path: str, target_slot_number: int, cloudlog, standalone=False) -> None:
verify_manifest_signature(manifest_path)
update = json.load(open(manifest_path))
cloudlog.info(f"Target slot {target_slot_number}")
os.system(f"abctl --set_unbootable {target_slot_number}")
for partition in update:
success = False
for retries in range(10):
try:
flash_partition(target_slot_number, partition, cloudlog, standalone)
success = True
break
except requests.exceptions.RequestException:
cloudlog.exception("Failed")
cloudlog.info(f"Failed to download {partition['name']}, retrying ({retries})")
time.sleep(10)
if not success:
cloudlog.info(f"Failed to flash {partition['name']}, aborting")
raise Exception("Maximum retries exceeded")
cloudlog.info(f"AGNOS ready on slot {target_slot_number}")
def verify_agnos_update(manifest_path: str, target_slot_number: int) -> bool:
verify_manifest_signature(manifest_path)
update = json.load(open(manifest_path))
return all(verify_partition(target_slot_number, partition) for partition in update)
if __name__ == "__main__":
import argparse
import logging
parser = argparse.ArgumentParser(description="Flash and verify AGNOS update",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--verify", action="store_true", help="Verify and perform swap if update ready")
parser.add_argument("--swap", action="store_true", help="Verify and perform swap, downloads if necessary")
parser.add_argument("manifest", help="Manifest json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
target_slot_number = get_target_slot_number()
if args.verify:
if verify_agnos_update(args.manifest, target_slot_number):
swap(args.manifest, target_slot_number, logging)
exit(0)
exit(1)
elif args.swap:
while not verify_agnos_update(args.manifest, target_slot_number):
logging.error("Verification failed. Flashing AGNOS")
flash_agnos_update(args.manifest, target_slot_number, logging, standalone=True)
logging.warning(f"Verification succeeded. Swapping to slot {target_slot_number}")
swap(args.manifest, target_slot_number, logging)
else:
flash_agnos_update(args.manifest, target_slot_number, logging, standalone=True)

View File

@@ -0,0 +1,80 @@
[
{
"name": "xbl",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz",
"hash": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"hash_raw": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"size": 3282256,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "d47a08914d2376557b03f1231b7233508222c04b57d781f9daf77c63eab92c2e"
},
{
"name": "xbl_config",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz",
"hash": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"hash_raw": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"size": 98124,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "e7d04d9f040c9c040cdf013335d0b6d6e9346311458baeb2461b193e954f5f1c"
},
{
"name": "abl",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz",
"hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6",
"hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6",
"size": 274432,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6"
},
{
"name": "aop",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz",
"hash": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"hash_raw": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"size": 184364,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3aa0a79149ec57f4bc8c38f7bbdf4f6630dd659e49a111ce6258d2d06a07c8e5"
},
{
"name": "devcfg",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz",
"hash": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"hash_raw": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"size": 40336,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3d7bb33588491a2a40091a7e1cf6cb65e6dd503f69b640aba484d723f1ad47e8"
},
{
"name": "boot",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/boot-595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4.img.xz",
"hash": "595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4",
"hash_raw": "595f6696c47c17d70a654d8ca04abca946a16dbb1da6250e464a23dff82258a4",
"size": 18216960,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "b0ee082b2e63a49fcfd1ca2adfb49275e1bb567889cbb9985827fb1de50f943b"
},
{
"name": "system",
"url": "https://git.konn3kt.com/IQ.Lvbs/iqos/raw/branch/master/system-44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa.img.xz",
"hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"hash_raw": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"size": 6291456000,
"sparse": false,
"full_check": false,
"has_ab": true,
"ondevice_hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa",
"url_parts": 10
}
]

View File

@@ -0,0 +1 @@
JqXKQi3b6oUtR8CYSq6qoeGjE4SRViVSqYcL8dbMgwXGBkEY2fVCYDrO3nhzhEu7yv8EWGuvK4WiMdGB52iJAQ==

View File

@@ -0,0 +1,400 @@
[
{
"name": "gpt_main_0",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_0-8928a31fd9ee20f8703649f89833eba9b55e84b6415e67799c777b163c95a0bd.img.xz",
"hash": "8928a31fd9ee20f8703649f89833eba9b55e84b6415e67799c777b163c95a0bd",
"hash_raw": "8928a31fd9ee20f8703649f89833eba9b55e84b6415e67799c777b163c95a0bd",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8928a31fd9ee20f8703649f89833eba9b55e84b6415e67799c777b163c95a0bd",
"gpt": {
"lun": 0,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "gpt_main_1",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_1-fe8ef7653db588d7420a625920ca06927dfcb0ed8aff3e3a1c74a52a24398ba6.img.xz",
"hash": "fe8ef7653db588d7420a625920ca06927dfcb0ed8aff3e3a1c74a52a24398ba6",
"hash_raw": "fe8ef7653db588d7420a625920ca06927dfcb0ed8aff3e3a1c74a52a24398ba6",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "fe8ef7653db588d7420a625920ca06927dfcb0ed8aff3e3a1c74a52a24398ba6",
"gpt": {
"lun": 1,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "gpt_main_2",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_2-5ccfc7240c8cbfa2f1a018a2e376cf274a6baf858c9bfe71951d8e28cab53c21.img.xz",
"hash": "5ccfc7240c8cbfa2f1a018a2e376cf274a6baf858c9bfe71951d8e28cab53c21",
"hash_raw": "5ccfc7240c8cbfa2f1a018a2e376cf274a6baf858c9bfe71951d8e28cab53c21",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "5ccfc7240c8cbfa2f1a018a2e376cf274a6baf858c9bfe71951d8e28cab53c21",
"gpt": {
"lun": 2,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "gpt_main_3",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_3-c707979fa21e89519328f4f30c2b21c9c453401ca8303f914c1873d410a95159.img.xz",
"hash": "c707979fa21e89519328f4f30c2b21c9c453401ca8303f914c1873d410a95159",
"hash_raw": "c707979fa21e89519328f4f30c2b21c9c453401ca8303f914c1873d410a95159",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "c707979fa21e89519328f4f30c2b21c9c453401ca8303f914c1873d410a95159",
"gpt": {
"lun": 3,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "gpt_main_4",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_4-e9405dcd785dbe79412184e1894a9c51ab7deb33bb612166c4c42a3d2bf42a0e.img.xz",
"hash": "e9405dcd785dbe79412184e1894a9c51ab7deb33bb612166c4c42a3d2bf42a0e",
"hash_raw": "e9405dcd785dbe79412184e1894a9c51ab7deb33bb612166c4c42a3d2bf42a0e",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "e9405dcd785dbe79412184e1894a9c51ab7deb33bb612166c4c42a3d2bf42a0e",
"gpt": {
"lun": 4,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "gpt_main_5",
"url": "https://commadist.azureedge.net/agnosupdate/gpt_main_5-21ae965f05b2fa8d02e04f1eb74718f9779864f6eacdeb859757d6435e8ccce3.img.xz",
"hash": "21ae965f05b2fa8d02e04f1eb74718f9779864f6eacdeb859757d6435e8ccce3",
"hash_raw": "21ae965f05b2fa8d02e04f1eb74718f9779864f6eacdeb859757d6435e8ccce3",
"size": 24576,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "21ae965f05b2fa8d02e04f1eb74718f9779864f6eacdeb859757d6435e8ccce3",
"gpt": {
"lun": 5,
"start_sector": 0,
"num_sectors": 6
}
},
{
"name": "persist",
"url": "https://commadist.azureedge.net/agnosupdate/persist-d6af4ec18df180c7417353b52a9e05e43a6480b29425f087874136436cefe786.img.xz",
"hash": "d6af4ec18df180c7417353b52a9e05e43a6480b29425f087874136436cefe786",
"hash_raw": "d6af4ec18df180c7417353b52a9e05e43a6480b29425f087874136436cefe786",
"size": 4096,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "d6af4ec18df180c7417353b52a9e05e43a6480b29425f087874136436cefe786"
},
{
"name": "systemrw",
"url": "https://commadist.azureedge.net/agnosupdate/systemrw-8ce150ca38ef64a0885fc2fe816e5b63bae8adb4df5d809c5b318e6996366c7e.img.xz",
"hash": "8ce150ca38ef64a0885fc2fe816e5b63bae8adb4df5d809c5b318e6996366c7e",
"hash_raw": "8ce150ca38ef64a0885fc2fe816e5b63bae8adb4df5d809c5b318e6996366c7e",
"size": 16777216,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8ce150ca38ef64a0885fc2fe816e5b63bae8adb4df5d809c5b318e6996366c7e"
},
{
"name": "cache",
"url": "https://commadist.azureedge.net/agnosupdate/cache-ebfbaaa2f96dc4e5fea4f126364e5bf5b3b44c12cbc753b62fdd8baab82f70b4.img.xz",
"hash": "ebfbaaa2f96dc4e5fea4f126364e5bf5b3b44c12cbc753b62fdd8baab82f70b4",
"hash_raw": "ebfbaaa2f96dc4e5fea4f126364e5bf5b3b44c12cbc753b62fdd8baab82f70b4",
"size": 134217728,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "ebfbaaa2f96dc4e5fea4f126364e5bf5b3b44c12cbc753b62fdd8baab82f70b4"
},
{
"name": "xbl",
"url": "https://commadist.azureedge.net/agnosupdate/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz",
"hash": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"hash_raw": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6",
"size": 3282256,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "d47a08914d2376557b03f1231b7233508222c04b57d781f9daf77c63eab92c2e"
},
{
"name": "xbl_config",
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz",
"hash": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"hash_raw": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9",
"size": 98124,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "e7d04d9f040c9c040cdf013335d0b6d6e9346311458baeb2461b193e954f5f1c"
},
{
"name": "abl",
"url": "https://commadist.azureedge.net/agnosupdate/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz",
"hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee",
"hash_raw": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee",
"size": 274432,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee"
},
{
"name": "aop",
"url": "https://commadist.azureedge.net/agnosupdate/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz",
"hash": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"hash_raw": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788",
"size": 184364,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3aa0a79149ec57f4bc8c38f7bbdf4f6630dd659e49a111ce6258d2d06a07c8e5"
},
{
"name": "bluetooth",
"url": "https://commadist.azureedge.net/agnosupdate/bluetooth-9bb766d2d2ce0cc4491664b3010fe1ef62f8ffc1e362d55f78e48c4141f75533.img.xz",
"hash": "9bb766d2d2ce0cc4491664b3010fe1ef62f8ffc1e362d55f78e48c4141f75533",
"hash_raw": "9bb766d2d2ce0cc4491664b3010fe1ef62f8ffc1e362d55f78e48c4141f75533",
"size": 1048576,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "9bb766d2d2ce0cc4491664b3010fe1ef62f8ffc1e362d55f78e48c4141f75533"
},
{
"name": "cmnlib64",
"url": "https://commadist.azureedge.net/agnosupdate/cmnlib64-1a876bd151bb9635f18719c4a17f953079de6e11d3eaec800968fc75669e0dc3.img.xz",
"hash": "1a876bd151bb9635f18719c4a17f953079de6e11d3eaec800968fc75669e0dc3",
"hash_raw": "1a876bd151bb9635f18719c4a17f953079de6e11d3eaec800968fc75669e0dc3",
"size": 524288,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "1a876bd151bb9635f18719c4a17f953079de6e11d3eaec800968fc75669e0dc3"
},
{
"name": "cmnlib",
"url": "https://commadist.azureedge.net/agnosupdate/cmnlib-63df823e8a5fae01d66cb2b8c20f0d2ddb5c5f2425e5d0992a64676273ba1c82.img.xz",
"hash": "63df823e8a5fae01d66cb2b8c20f0d2ddb5c5f2425e5d0992a64676273ba1c82",
"hash_raw": "63df823e8a5fae01d66cb2b8c20f0d2ddb5c5f2425e5d0992a64676273ba1c82",
"size": 524288,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "63df823e8a5fae01d66cb2b8c20f0d2ddb5c5f2425e5d0992a64676273ba1c82"
},
{
"name": "devcfg",
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz",
"hash": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"hash_raw": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585",
"size": 40336,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "3d7bb33588491a2a40091a7e1cf6cb65e6dd503f69b640aba484d723f1ad47e8"
},
{
"name": "devinfo",
"url": "https://commadist.azureedge.net/agnosupdate/devinfo-143869c499a7e878fbeab756e9c53074195770cc41d6d0d10e45c043141389a3.img.xz",
"hash": "143869c499a7e878fbeab756e9c53074195770cc41d6d0d10e45c043141389a3",
"hash_raw": "143869c499a7e878fbeab756e9c53074195770cc41d6d0d10e45c043141389a3",
"size": 4096,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "143869c499a7e878fbeab756e9c53074195770cc41d6d0d10e45c043141389a3"
},
{
"name": "dsp",
"url": "https://commadist.azureedge.net/agnosupdate/dsp-4b15fbd2f45581f1553f33f01649e450b24aa19d5deff2ac7dcb16a534d9c248.img.xz",
"hash": "4b15fbd2f45581f1553f33f01649e450b24aa19d5deff2ac7dcb16a534d9c248",
"hash_raw": "4b15fbd2f45581f1553f33f01649e450b24aa19d5deff2ac7dcb16a534d9c248",
"size": 33554432,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "4b15fbd2f45581f1553f33f01649e450b24aa19d5deff2ac7dcb16a534d9c248"
},
{
"name": "hyp",
"url": "https://commadist.azureedge.net/agnosupdate/hyp-ff5ece6a4e3d2b4d898c77ffe193fc8bbc8acebe78263996ecf52373d8088927.img.xz",
"hash": "ff5ece6a4e3d2b4d898c77ffe193fc8bbc8acebe78263996ecf52373d8088927",
"hash_raw": "ff5ece6a4e3d2b4d898c77ffe193fc8bbc8acebe78263996ecf52373d8088927",
"size": 524288,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "ff5ece6a4e3d2b4d898c77ffe193fc8bbc8acebe78263996ecf52373d8088927"
},
{
"name": "keymaster",
"url": "https://commadist.azureedge.net/agnosupdate/keymaster-5c968c76f29b9a4d66fbe57e639bac6b7a2c83b1758e25abbaf5d276b8a6af04.img.xz",
"hash": "5c968c76f29b9a4d66fbe57e639bac6b7a2c83b1758e25abbaf5d276b8a6af04",
"hash_raw": "5c968c76f29b9a4d66fbe57e639bac6b7a2c83b1758e25abbaf5d276b8a6af04",
"size": 524288,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "5c968c76f29b9a4d66fbe57e639bac6b7a2c83b1758e25abbaf5d276b8a6af04"
},
{
"name": "limits",
"url": "https://commadist.azureedge.net/agnosupdate/limits-94951a0f7aa55fb6cb975535ce4ebbfe6d695f04cb5424677b01c10dfa2e94e1.img.xz",
"hash": "94951a0f7aa55fb6cb975535ce4ebbfe6d695f04cb5424677b01c10dfa2e94e1",
"hash_raw": "94951a0f7aa55fb6cb975535ce4ebbfe6d695f04cb5424677b01c10dfa2e94e1",
"size": 4096,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "94951a0f7aa55fb6cb975535ce4ebbfe6d695f04cb5424677b01c10dfa2e94e1"
},
{
"name": "logfs",
"url": "https://commadist.azureedge.net/agnosupdate/logfs-b8b5ac87f3d954404fc7ecbdd9ee3b5b0cf5691e5006e6ec55db4c899ff61220.img.xz",
"hash": "b8b5ac87f3d954404fc7ecbdd9ee3b5b0cf5691e5006e6ec55db4c899ff61220",
"hash_raw": "b8b5ac87f3d954404fc7ecbdd9ee3b5b0cf5691e5006e6ec55db4c899ff61220",
"size": 8388608,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "b8b5ac87f3d954404fc7ecbdd9ee3b5b0cf5691e5006e6ec55db4c899ff61220"
},
{
"name": "modem",
"url": "https://commadist.azureedge.net/agnosupdate/modem-a3d014f0896d77a2df7e5a80a70f43a51a047b9d03cfc675b6f0e31a6ecc4994.img.xz",
"hash": "a3d014f0896d77a2df7e5a80a70f43a51a047b9d03cfc675b6f0e31a6ecc4994",
"hash_raw": "a3d014f0896d77a2df7e5a80a70f43a51a047b9d03cfc675b6f0e31a6ecc4994",
"size": 125829120,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "a3d014f0896d77a2df7e5a80a70f43a51a047b9d03cfc675b6f0e31a6ecc4994"
},
{
"name": "qupfw",
"url": "https://commadist.azureedge.net/agnosupdate/qupfw-64cc7c29d5d69b04267452b8b4ddba9f4809e68f476fc162ca283f58537afe4a.img.xz",
"hash": "64cc7c29d5d69b04267452b8b4ddba9f4809e68f476fc162ca283f58537afe4a",
"hash_raw": "64cc7c29d5d69b04267452b8b4ddba9f4809e68f476fc162ca283f58537afe4a",
"size": 65536,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "64cc7c29d5d69b04267452b8b4ddba9f4809e68f476fc162ca283f58537afe4a"
},
{
"name": "splash",
"url": "https://commadist.azureedge.net/agnosupdate/splash-5c61260048f22ede6e6343fabb27f6ff73f9271f4751a01aaf7abf097afc1f08.img.xz",
"hash": "5c61260048f22ede6e6343fabb27f6ff73f9271f4751a01aaf7abf097afc1f08",
"hash_raw": "5c61260048f22ede6e6343fabb27f6ff73f9271f4751a01aaf7abf097afc1f08",
"size": 34226176,
"sparse": false,
"full_check": true,
"has_ab": false,
"ondevice_hash": "5c61260048f22ede6e6343fabb27f6ff73f9271f4751a01aaf7abf097afc1f08"
},
{
"name": "storsec",
"url": "https://commadist.azureedge.net/agnosupdate/storsec-4494d86f68b125fbf2c004c824b1c6dbe71e61a65d2a1cc7db13c553edcb3fce.img.xz",
"hash": "4494d86f68b125fbf2c004c824b1c6dbe71e61a65d2a1cc7db13c553edcb3fce",
"hash_raw": "4494d86f68b125fbf2c004c824b1c6dbe71e61a65d2a1cc7db13c553edcb3fce",
"size": 131072,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "4494d86f68b125fbf2c004c824b1c6dbe71e61a65d2a1cc7db13c553edcb3fce"
},
{
"name": "tz",
"url": "https://commadist.azureedge.net/agnosupdate/tz-e9443bf187641661bfa6c96702b9ab0156e72fb7482500f8799ba9ee2503cb16.img.xz",
"hash": "e9443bf187641661bfa6c96702b9ab0156e72fb7482500f8799ba9ee2503cb16",
"hash_raw": "e9443bf187641661bfa6c96702b9ab0156e72fb7482500f8799ba9ee2503cb16",
"size": 2097152,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "e9443bf187641661bfa6c96702b9ab0156e72fb7482500f8799ba9ee2503cb16"
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756.img.xz",
"hash": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"hash_raw": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"size": 17496064,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "0ee1ab104bb46d0f72e7d0b7d3e94629a7644a368896c6d4c558554fb955a08a"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img.xz",
"hash": "7c58308be461126677ba02e9c9739556520ee02958934733867d86ecfe2e58e9",
"hash_raw": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "826790516410c325aa30265846946d06a556f0a7b23c957f65fd11c055a663da",
"alt": {
"hash": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img",
"size": 4718592000
}
},
{
"name": "userdata_90",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb.img.xz",
"hash": "aa0f0fe32187493e6135aee9e984d3f9705fc58560d537b34687bb6b51a38428",
"hash_raw": "ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb",
"size": 96636764160,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "9c916b7d05543d4608b0401bc867639f44ce9671639a1a6da83b6d58b4eaa1b4"
},
{
"name": "userdata_89",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11.img.xz",
"hash": "fa83d4b7096857136820b0b0a8785c90677256b054c5c14039cd7b9b1065a90b",
"hash_raw": "7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11",
"size": 95563022336,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "1699e38de769eb32c21dfa6a5ac21eb3ad620a362c7b8abf1a2c0afe0f717530"
},
{
"name": "userdata_30",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed.img.xz",
"hash": "890910f20b1ad88a728ee822a47b1234eb3d70cab28ca8a935679c8c2d33cbe9",
"hash_raw": "3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed",
"size": 32212254720,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8e7cb392dd6e49c7d59fa850be7d1f44901314c86ba9c88be5bb27a0cd1123c9"
}
]

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import time
from collections import namedtuple
from iqpilot.common.i2c import SMBus
# https://datasheets.maximintegrated.com/en/ds/MAX98089.pdf
AmpConfig = namedtuple('AmpConfig', ['name', 'value', 'register', 'offset', 'mask'])
EQParams = namedtuple('EQParams', ['K', 'k1', 'k2', 'c1', 'c2'])
def configs_from_eq_params(base, eq_params):
return [
AmpConfig("K (high)", (eq_params.K >> 8), base, 0, 0xFF),
AmpConfig("K (low)", (eq_params.K & 0xFF), base + 1, 0, 0xFF),
AmpConfig("k1 (high)", (eq_params.k1 >> 8), base + 2, 0, 0xFF),
AmpConfig("k1 (low)", (eq_params.k1 & 0xFF), base + 3, 0, 0xFF),
AmpConfig("k2 (high)", (eq_params.k2 >> 8), base + 4, 0, 0xFF),
AmpConfig("k2 (low)", (eq_params.k2 & 0xFF), base + 5, 0, 0xFF),
AmpConfig("c1 (high)", (eq_params.c1 >> 8), base + 6, 0, 0xFF),
AmpConfig("c1 (low)", (eq_params.c1 & 0xFF), base + 7, 0, 0xFF),
AmpConfig("c2 (high)", (eq_params.c2 >> 8), base + 8, 0, 0xFF),
AmpConfig("c2 (low)", (eq_params.c2 & 0xFF), base + 9, 0, 0xFF),
]
BASE_CONFIG = [
AmpConfig("MCLK prescaler", 0b01, 0x10, 4, 0b00110000),
AmpConfig("PM: enable speakers", 0b11, 0x4D, 4, 0b00110000),
AmpConfig("PM: enable DACs", 0b11, 0x4D, 0, 0b00000011),
AmpConfig("Enable PLL1", 0b1, 0x12, 7, 0b10000000),
AmpConfig("Enable PLL2", 0b1, 0x1A, 7, 0b10000000),
AmpConfig("DAI1: I2S mode", 0b00100, 0x14, 2, 0b01111100),
AmpConfig("DAI2: I2S mode", 0b00100, 0x1C, 2, 0b01111100),
AmpConfig("DAI1 Passband filtering: music mode", 0b1, 0x18, 7, 0b10000000),
AmpConfig("DAI1 voice mode gain (DV1G)", 0b00, 0x2F, 4, 0b00110000),
AmpConfig("DAI1 attenuation (DV1)", 0x0, 0x2F, 0, 0b00001111),
AmpConfig("DAI2 attenuation (DV2)", 0x0, 0x31, 0, 0b00001111),
AmpConfig("DAI2: DC blocking", 0b1, 0x20, 0, 0b00000001),
AmpConfig("DAI2: High sample rate", 0b0, 0x20, 3, 0b00001000),
AmpConfig("ALC enable", 0b1, 0x43, 7, 0b10000000),
AmpConfig("ALC/excursion limiter release time", 0b101, 0x43, 4, 0b01110000),
AmpConfig("ALC multiband enable", 0b1, 0x43, 3, 0b00001000),
AmpConfig("DAI1 EQ enable", 0b0, 0x49, 0, 0b00000001),
AmpConfig("DAI2 EQ clip detection disabled", 0b1, 0x32, 4, 0b00010000),
AmpConfig("DAI2 EQ attenuation", 0x5, 0x32, 0, 0b00001111),
AmpConfig("Excursion limiter upper corner freq", 0b100, 0x41, 4, 0b01110000),
AmpConfig("Excursion limiter lower corner freq", 0b00, 0x41, 0, 0b00000011),
AmpConfig("Excursion limiter threshold", 0b000, 0x42, 0, 0b00001111),
AmpConfig("Distortion limit (THDCLP)", 0x6, 0x46, 4, 0b11110000),
AmpConfig("Distortion limiter release time constant", 0b0, 0x46, 0, 0b00000001),
AmpConfig("Right DAC input mixer: DAI1 left", 0b0, 0x22, 3, 0b00001000),
AmpConfig("Right DAC input mixer: DAI1 right", 0b0, 0x22, 2, 0b00000100),
AmpConfig("Right DAC input mixer: DAI2 left", 0b1, 0x22, 1, 0b00000010),
AmpConfig("Right DAC input mixer: DAI2 right", 0b0, 0x22, 0, 0b00000001),
AmpConfig("DAI1 audio port selector", 0b10, 0x16, 6, 0b11000000),
AmpConfig("DAI2 audio port selector", 0b01, 0x1E, 6, 0b11000000),
AmpConfig("Enable left digital microphone", 0b1, 0x48, 5, 0b00100000),
AmpConfig("Enable right digital microphone", 0b1, 0x48, 4, 0b00010000),
AmpConfig("Enhanced volume smoothing disabled", 0b0, 0x49, 7, 0b10000000),
AmpConfig("Volume adjustment smoothing disabled", 0b0, 0x49, 6, 0b01000000),
AmpConfig("Zero-crossing detection disabled", 0b0, 0x49, 5, 0b00100000),
]
CONFIGS = {
"tici": [
AmpConfig("Right speaker output from right DAC", 0b1, 0x2C, 0, 0b11111111),
AmpConfig("Right Speaker Mixer Gain", 0b00, 0x2D, 2, 0b00001100),
AmpConfig("Right speaker output volume", 0x1c, 0x3E, 0, 0b00011111),
AmpConfig("DAI2 EQ enable", 0b1, 0x49, 1, 0b00000010),
*configs_from_eq_params(0x84, EQParams(0x274F, 0xC0FF, 0x3BF9, 0x0B3C, 0x1656)),
*configs_from_eq_params(0x8E, EQParams(0x1009, 0xC6BF, 0x2952, 0x1C97, 0x30DF)),
*configs_from_eq_params(0x98, EQParams(0x0F75, 0xCBE5, 0x0ED2, 0x2528, 0x3E42)),
*configs_from_eq_params(0xA2, EQParams(0x091F, 0x3D4C, 0xCE11, 0x1266, 0x2807)),
*configs_from_eq_params(0xAC, EQParams(0x0A9E, 0x3F20, 0xE573, 0x0A8B, 0x3A3B)),
],
"tizi": [
AmpConfig("Left speaker output from left DAC", 0b1, 0x2B, 0, 0b11111111),
AmpConfig("Right speaker output from right DAC", 0b1, 0x2C, 0, 0b11111111),
AmpConfig("Left Speaker Mixer Gain", 0b00, 0x2D, 0, 0b00000011),
AmpConfig("Right Speaker Mixer Gain", 0b00, 0x2D, 2, 0b00001100),
AmpConfig("Left speaker output volume", 0x17, 0x3D, 0, 0b00011111),
AmpConfig("Right speaker output volume", 0x17, 0x3E, 0, 0b00011111),
AmpConfig("DAI2 EQ enable", 0b0, 0x49, 1, 0b00000010),
AmpConfig("DAI2: DC blocking", 0b0, 0x20, 0, 0b00000001),
AmpConfig("ALC enable", 0b0, 0x43, 7, 0b10000000),
AmpConfig("DAI2 EQ attenuation", 0x2, 0x32, 0, 0b00001111),
AmpConfig("Excursion limiter upper corner freq", 0b001, 0x41, 4, 0b01110000),
AmpConfig("Excursion limiter threshold", 0b100, 0x42, 0, 0b00001111),
AmpConfig("Distortion limit (THDCLP)", 0x0, 0x46, 4, 0b11110000),
AmpConfig("Distortion limiter release time constant", 0b1, 0x46, 0, 0b00000001),
AmpConfig("Left DAC input mixer: DAI1 left", 0b0, 0x22, 7, 0b10000000),
AmpConfig("Left DAC input mixer: DAI1 right", 0b0, 0x22, 6, 0b01000000),
AmpConfig("Left DAC input mixer: DAI2 left", 0b1, 0x22, 5, 0b00100000),
AmpConfig("Left DAC input mixer: DAI2 right", 0b0, 0x22, 4, 0b00010000),
AmpConfig("Right DAC input mixer: DAI2 left", 0b0, 0x22, 1, 0b00000010),
AmpConfig("Right DAC input mixer: DAI2 right", 0b1, 0x22, 0, 0b00000001),
AmpConfig("Volume adjustment smoothing disabled", 0b1, 0x49, 6, 0b01000000),
],
}
class Amplifier:
AMP_I2C_BUS = 0
AMP_ADDRESS = 0x10
def __init__(self, debug=False):
self.debug = debug
def _get_shutdown_config(self, amp_disabled: bool) -> AmpConfig:
return AmpConfig("Global shutdown", 0b0 if amp_disabled else 0b1, 0x51, 7, 0b10000000)
def _set_configs(self, configs: list[AmpConfig]) -> None:
with SMBus(self.AMP_I2C_BUS) as bus:
for config in configs:
if self.debug:
print(f"Setting \"{config.name}\" to {config.value}:")
old_value = bus.read_byte_data(self.AMP_ADDRESS, config.register, force=True)
new_value = (old_value & (~config.mask)) | ((config.value << config.offset) & config.mask)
bus.write_byte_data(self.AMP_ADDRESS, config.register, new_value, force=True)
if self.debug:
print(f" Changed {hex(config.register)}: {hex(old_value)} -> {hex(new_value)}")
def set_configs(self, configs: list[AmpConfig]) -> bool:
tries = 15
backoff = 0.
for i in range(tries):
try:
self._set_configs(configs)
return True
except OSError:
backoff += 0.1
time.sleep(backoff)
print(f"Failed to set amp config, {tries - i - 1} retries left")
return False
def set_global_shutdown(self, amp_disabled: bool) -> bool:
return self.set_configs([self._get_shutdown_config(amp_disabled), ])
def initialize_configuration(self, model: str) -> bool:
cfgs = [
self._get_shutdown_config(True),
*BASE_CONFIG,
*CONFIGS[model],
self._get_shutdown_config(False),
]
return self.set_configs(cfgs)
if __name__ == "__main__":
with open("/sys/firmware/devicetree/base/model") as f:
model = f.read().strip('\x00')
model = model.split('comma ')[-1]
amp = Amplifier()
amp.initialize_configuration(model)

View File

@@ -0,0 +1,30 @@
[connection]
id=esim
uuid=fff6553c-3284-4707-a6b1-acc021caaafb
type=gsm
permissions=
autoconnect=true
autoconnect-retries=100
autoconnect-priority=2
metered=1
[gsm]
apn=
home-only=false
auto-config=true
sim-id=
[ipv4]
route-metric=1000
dns-priority=1000
dns-search=
method=auto
[ipv6]
ddr-gen-mode=stable-privacy
dns-search=
route-metric=1000
dns-priority=1000
method=auto
[proxy]

View File

@@ -0,0 +1,290 @@
import threading
import subprocess
import time
from dataclasses import dataclass
from enum import Enum
from queue import Queue, Empty
from typing import Callable
from iqpilot.common.params import Params
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.hardware.base import LPAError, LPAProfileNotFoundError, Profile
class EsimOperationState(Enum):
IDLE = "idle"
SCANNING = "scanning"
DOWNLOADING = "downloading"
SWITCHING = "switching"
RENAMING = "renaming"
DELETING = "deleting"
REBOOTING_MODEM = "rebooting modem"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class EsimUiState:
state: EsimOperationState = EsimOperationState.IDLE
message: str = ""
profiles: list[Profile] | None = None
busy: bool = False
class EsimManager:
def __init__(self):
self._params = Params()
self._lock = threading.Lock()
self._callbacks: list[Callable[[EsimUiState], None]] = []
self._state = EsimUiState()
self._support_cache: bool | None = None
self._support_cache_ts = 0.0
self._ops: Queue[Callable[[], None]] = Queue()
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
self._worker.start()
def is_supported(self) -> bool:
raw_flag = self._params.get("EnableEsimProvisioning")
enabled = True if raw_flag is None else self._params.get_bool("EnableEsimProvisioning")
if not enabled:
return False
if HARDWARE.get_device_type() not in ("tici", "tizi", "mici"):
return False
return self._has_euicc()
def _has_euicc(self, force_refresh: bool = False) -> bool:
now = time.monotonic()
if not force_refresh and self._support_cache is not None and now - self._support_cache_ts < 5.0:
return self._support_cache
supported = self._query_euicc_support()
self._support_cache = supported
self._support_cache_ts = now
return supported
@staticmethod
def _query_euicc_support() -> bool:
try:
res = subprocess.run(
["sudo", "qmicli", "-p", "-d", "/dev/cdc-wdm0", "--uim-get-slot-status"],
capture_output=True, text=True, check=False, timeout=8,
)
except Exception:
return False
output = f"{res.stdout}\n{res.stderr}"
if "Is eUICC: yes" in output:
return True
if "Is eUICC: no" in output:
return False
return False
def add_callback(self, cb: Callable[[EsimUiState], None]) -> None:
with self._lock:
self._callbacks.append(cb)
state = self._copy_state_locked()
cb(state)
def remove_callback(self, cb: Callable[[EsimUiState], None]) -> None:
with self._lock:
self._callbacks = [c for c in self._callbacks if c is not cb]
def get_state(self) -> EsimUiState:
with self._lock:
return self._copy_state_locked()
def refresh_profiles(self) -> None:
if not self._is_supported_for_operation():
self._set_profiles([])
self._set_state(EsimOperationState.IDLE, self._unavailable_message(), busy=False)
return
self._enqueue(self._refresh_profiles)
def is_comma_profile(self, iccid: str) -> bool:
try:
return HARDWARE.get_sim_lpa().is_comma_profile(iccid)
except Exception:
return False
def add_profile(self, activation_code: str, nickname: str | None = None) -> None:
def _op() -> None:
self._set_state(EsimOperationState.DOWNLOADING, "Downloading profile...", busy=True)
lpa = HARDWARE.get_sim_lpa()
lpa.download_profile(activation_code, nickname=nickname)
self._set_state(EsimOperationState.REBOOTING_MODEM, "Reconnecting modem...", busy=True)
self._refresh_profiles()
self._set_state(EsimOperationState.COMPLETED, "Profile added", busy=False)
self._enqueue(_op)
def switch_profile(self, iccid: str) -> None:
def _op() -> None:
self._set_state(EsimOperationState.SWITCHING, "Switching profile...", busy=True)
lpa = HARDWARE.get_sim_lpa()
lpa.switch_profile(iccid)
self._set_state(EsimOperationState.REBOOTING_MODEM, "Reconnecting modem...", busy=True)
self._refresh_profiles()
self._set_state(EsimOperationState.COMPLETED, "Profile switched", busy=False)
self._enqueue(_op)
def rename_profile(self, iccid: str, nickname: str) -> None:
def _op() -> None:
self._set_state(EsimOperationState.RENAMING, "Renaming profile...", busy=True)
lpa = HARDWARE.get_sim_lpa()
lpa.nickname_profile(iccid, nickname)
self._refresh_profiles()
self._set_state(EsimOperationState.COMPLETED, "Profile renamed", busy=False)
self._enqueue(_op)
def delete_profile(self, iccid: str) -> None:
def _op() -> None:
self._set_state(EsimOperationState.DELETING, "Deleting profile...", busy=True)
lpa = HARDWARE.get_sim_lpa()
lpa.delete_profile(iccid)
self._set_state(EsimOperationState.REBOOTING_MODEM, "Reconnecting modem...", busy=True)
self._refresh_profiles()
self._set_state(EsimOperationState.COMPLETED, "Profile deleted", busy=False)
self._enqueue(_op)
def bootstrap(self) -> None:
def _op() -> None:
self._set_state(EsimOperationState.DELETING, "Removing Comma pSIM...", busy=True)
lpa = HARDWARE.get_sim_lpa()
lpa.bootstrap()
self._set_state(EsimOperationState.REBOOTING_MODEM, "Reconnecting modem...", busy=True)
self._refresh_profiles()
self._set_state(EsimOperationState.COMPLETED, "Comma pSIM removed", busy=False)
self._enqueue(_op)
def set_scanning_state(self, scanning: bool) -> None:
if scanning:
self._set_state(EsimOperationState.SCANNING, "Point camera at an eSIM QR code", busy=True)
else:
self._set_state(EsimOperationState.IDLE, "", busy=False)
def _enqueue(self, fn: Callable[[], None]) -> None:
if not self._is_supported_for_operation():
self._set_state(EsimOperationState.FAILED, self._unavailable_message(), busy=False)
return
self._ops.put(fn)
def _is_supported_for_operation(self) -> bool:
raw_flag = self._params.get("EnableEsimProvisioning")
enabled = True if raw_flag is None else self._params.get_bool("EnableEsimProvisioning")
if not enabled:
return False
if HARDWARE.get_device_type() not in ("tici", "tizi", "mici"):
return False
return self._has_euicc(force_refresh=True)
def _unavailable_message(self) -> str:
if HARDWARE.get_device_type() in ("tici", "tizi", "mici"):
return "Insert the original comma SIM card that came with the device to use eSIM"
return "eSIM provisioning is unavailable on this device"
def _worker_loop(self) -> None:
while True:
try:
op = self._ops.get(timeout=0.2)
except Empty:
continue
try:
op()
except Exception as e:
self._set_state(EsimOperationState.FAILED, self._map_error(e), busy=False)
finally:
self._ops.task_done()
def _refresh_profiles(self) -> None:
if not self._is_supported_for_operation():
self._set_profiles([])
return
profiles = HARDWARE.get_sim_lpa().list_profiles()
self._set_profiles(profiles)
def _set_profiles(self, profiles: list[Profile]) -> None:
with self._lock:
self._state.profiles = profiles
state = self._copy_state_locked()
callbacks = list(self._callbacks)
for cb in callbacks:
cb(state)
def _set_state(self, state: EsimOperationState, message: str, busy: bool) -> None:
with self._lock:
self._state.state = state
self._state.message = message
self._state.busy = busy
snapshot = self._copy_state_locked()
callbacks = list(self._callbacks)
for cb in callbacks:
cb(snapshot)
def _copy_state_locked(self) -> EsimUiState:
profiles = list(self._state.profiles) if self._state.profiles is not None else None
return EsimUiState(
state=self._state.state,
message=self._state.message,
profiles=profiles,
busy=self._state.busy,
)
@staticmethod
def _map_error(error: Exception) -> str:
if isinstance(error, LPAProfileNotFoundError):
return "Profile not found"
if isinstance(error, LPAError):
message = str(error)
lower = message.lower()
if "is euicc: no" in lower or "reports no euicc support" in lower:
return "Insert the original comma SIM to enable eSIM provisioning on this device"
if "certificate verify failed" in lower or "ssl" in lower or "tls" in lower:
return "TLS validation failed while contacting SM-DP+"
if "system time is not set" in lower:
return "Device time is invalid; connect to network and retry"
if "returned no modems" in lower or "object does not exist at path" in lower:
return "Modem is restarting; wait a moment and refresh profiles"
if "timed out" in lower or "timeout" in lower:
return "Modem timed out while provisioning eSIM"
if "delete the existing comma psim profile" in lower:
return "Delete the Comma pSIM profile before activating RedPocket"
if "not bootstrapped" in lower:
return "Delete the Comma pSIM profile before using user eSIM profiles"
if "cannot delete active profile" in lower:
return "Cannot delete active profile"
if "profile delete may have succeeded" in lower:
return "Profile may already be deleted; refresh profiles"
if "profile delete did not finish cleanly" in lower:
return "Profile delete did not complete; refresh profiles and retry"
if "profile switch may have succeeded" in lower:
return "Profile likely switched; refresh profiles"
if "profile switch did not finish cleanly" in lower:
return "Profile switch did not complete; refresh profiles and retry"
if "profile add may have succeeded" in lower:
return "Profile may already be added; refresh profiles"
if "profile add did not finish cleanly" in lower:
return "Profile add did not complete; refresh profiles and retry"
if "profile enable may have succeeded" in lower:
return "Profile may already be enabled; refresh profiles"
if "profile enable did not finish cleanly" in lower:
return "Profile enable did not complete; refresh profiles and retry"
if "profile disable may have succeeded" in lower:
return "Profile may already be disabled; refresh profiles"
if "profile disable did not finish cleanly" in lower:
return "Profile disable did not complete; refresh profiles and retry"
if "bf2800" in lower or "listnotification" in lower:
return "Modem notification cleanup failed; refresh profiles"
return message
return str(error)
_ESIM_MANAGER: EsimManager | None = None
_ESIM_MANAGER_LOCK = threading.Lock()
def get_esim_manager() -> EsimManager:
global _ESIM_MANAGER
with _ESIM_MANAGER_LOCK:
if _ESIM_MANAGER is None:
_ESIM_MANAGER = EsimManager()
return _ESIM_MANAGER

View File

@@ -0,0 +1,133 @@
# GSMA Certificate Issuer (CI) bundle for eSIM RSP
# Source: https://euicc-manual.osmocom.org/docs/pki/ci/bundle.pem
issuer=
countryName = CH
organizationName = OISTE Foundation
commonName = OISTE GSMA CI G1
notBefore=2024-01-16 23:17:39Z
notAfter=2059-01-07 23:17:38Z
-----BEGIN CERTIFICATE-----
MIIB9zCCAZ2gAwIBAgIUSpBSCCDYPOEG/IFHUCKpZ2pIAQMwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xGTAXBgNV
BAMMEE9JU1RFIEdTTUEgQ0kgRzEwIBcNMjQwMTE2MjMxNzM5WhgPMjA1OTAxMDcy
MzE3MzhaMEMxCzAJBgNVBAYTAkNIMRkwFwYDVQQKDBBPSVNURSBGb3VuZGF0aW9u
MRkwFwYDVQQDDBBPSVNURSBHU01BIENJIEcxMFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEvZ3s3PFC4NgrCcCMmHJ6DJ66uzAHuLcvjJnOn+TtBNThS7YHLDyHCa2v
7D+zTP+XTtgqgcLoB56Gha9EQQQ4xKNtMGswDwYDVR0TAQH/BAUwAwEB/zAQBgNV
HREECTAHiAVghXQFDjAXBgNVHSABAf8EDTALMAkGB2eBEgECAQAwHQYDVR0OBBYE
FEwnlnrSDBSzkelgHkHmBK1XwCIvMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQD
AgNIADBFAiBVcywTj017jKpAQ+gwy4MqK2hQvzve6lkvQkgSP6ykHwIhAI0KFwCD
jnPbmcJsG41hUrWNlf+IcrMvFuYii0DasBNi
-----END CERTIFICATE-----
issuer=
organizationName = GSM Association
commonName = GSM Association - RSP2 Root CI1
notBefore=2017-02-22 00:00:00Z
notAfter=2052-02-21 23:59:59Z
-----BEGIN CERTIFICATE-----
MIICSTCCAe+gAwIBAgIQbmhWeneg7nyF7hg5Y9+qejAKBggqhkjOPQQDAjBEMRgw
FgYDVQQKEw9HU00gQXNzb2NpYXRpb24xKDAmBgNVBAMTH0dTTSBBc3NvY2lhdGlv
biAtIFJTUDIgUm9vdCBDSTEwIBcNMTcwMjIyMDAwMDAwWhgPMjA1MjAyMjEyMzU5
NTlaMEQxGDAWBgNVBAoTD0dTTSBBc3NvY2lhdGlvbjEoMCYGA1UEAxMfR1NNIEFz
c29jaWF0aW9uIC0gUlNQMiBSb290IENJMTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABJ1qutL0HCMX52GJ6/jeibsAqZfULWj/X10p/Min6seZN+hf5llovbCNuB2n
unLz+O8UD0SUCBUVo8e6n9X1TuajgcAwgb0wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud
EwEB/wQFMAMBAf8wEwYDVR0RBAwwCogIKwYBBAGC6WAwFwYDVR0gAQH/BA0wCzAJ
BgdngRIBAgEAME0GA1UdHwRGMEQwQqBAoD6GPGh0dHA6Ly9nc21hLWNybC5zeW1h
dXRoLmNvbS9vZmZsaW5lY2EvZ3NtYS1yc3AyLXJvb3QtY2kxLmNybDAdBgNVHQ4E
FgQUgTcPUSXQsdQI1MOyMubSXnlb6/swCgYIKoZIzj0EAwIDSAAwRQIgIJdYsOMF
WziPK7l8nh5mu0qiRiVf25oa9ullG/OIASwCIQDqCmDrYf+GziHXBOiwJwnBaeBO
aFsiLzIEOaUuZwdNUw==
-----END CERTIFICATE-----
issuer=
countryName = US
organizationName = Entrust, Inc.
organizationalUnitName = See www.entrust.net/legal-terms
organizationalUnitName = (c) 2016 Entrust, Inc. - for authorized use only
commonName = Entrust eSIM Certification Authority
notBefore=2016-11-16 16:04:02Z
notAfter=2051-10-16 16:34:02Z
-----BEGIN CERTIFICATE-----
MIIC6DCCAo2gAwIBAgIRAIy4GT7M5nHsAAAAAFgsinowCgYIKoZIzj0EAwIwgbkx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9T
ZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAx
NiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxLTArBgNV
BAMTJEVudHJ1c3QgZVNJTSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0xNjEx
MTYxNjA0MDJaGA8yMDUxMTAxNjE2MzQwMlowgbkxCzAJBgNVBAYTAlVTMRYwFAYD
VQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0
L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNiBFbnRydXN0LCBJbmMuIC0g
Zm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxLTArBgNVBAMTJEVudHJ1c3QgZVNJTSBD
ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
BAdzwGHeQ1Wb2f4DmHTByR5/IWL3JugQ1U3908a++bHdlt+TTA7K4c5cYZ+51Yz/
hg/bacxguPDh9uQUK6Wg3a6jcjBwMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/
BAQDAgEGMBcGA1UdIAEB/wQNMAswCQYHZ4ESAQIBADAVBgNVHREEDjAMiApghkgB
hvpsFAoAMB0GA1UdDgQWBBQWcEt/NR42B/GMS3AAXDoAPf1BSjAKBggqhkjOPQQD
AgNJADBGAiEAspjXMvaBZyAg86Z0AAtT0yBRAi1EyaAfNz9kDJeAE04CIQC3efj8
ATL7/tDBOhANy3cK8PS/1NIlu9vqMLCZsZvJ0Q==
-----END CERTIFICATE-----
issuer=
countryName = FR
organizationName = OBERTHUR TECHNOLOGIES
organizationalUnitName = TELECOM
commonName = MC4 OT ROOT CI v1
notBefore=2016-11-15 00:00:01Z
notAfter=2046-11-08 23:59:59Z
-----BEGIN CERTIFICATE-----
MIICOjCCAeGgAwIBAgIBATAKBggqhkjOPQQDAjBbMQswCQYDVQQGEwJGUjEeMBwG
A1UEChMVT0JFUlRIVVIgVEVDSE5PTE9HSUVTMRAwDgYDVQQLEwdURUxFQ09NMRow
GAYDVQQDExFNQzQgT1QgUk9PVCBDSSB2MTAeFw0xNjExMTUwMDAwMDFaFw00NjEx
MDgyMzU5NTlaMFsxCzAJBgNVBAYTAkZSMR4wHAYDVQQKExVPQkVSVEhVUiBURUNI
Tk9MT0dJRVMxEDAOBgNVBAsTB1RFTEVDT00xGjAYBgNVBAMTEU1DNCBPVCBST09U
IENJIHYxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHb/Gajt3OZxuaDSklBQE
D4lOd6PGPLSvtfkM952ubdyy45tJwAeA0eEii0CLrFT6tcfXkW+H/5mQyMRXaAUk
T6OBlTCBkjAfBgNVHSMEGDAWgBTNbmC3LXoGPLyEYluR6A/jBAbhPjAdBgNVHQ4E
FgQUzW5gty16Bjy8hGJbkegP4wQG4T4wDgYDVR0PAQH/BAQDAgAGMBcGA1UdIAEB
/wQNMAswCQYHZ4ESAQIBADAWBgNVHREEDzANiAsrBgEEAYHvb7OITTAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIEw4Nc7f2fDtoH+6ON/bknfDQxmT
ikThXjhpLtSrSKN2AiAxHxgC87L0FDnH8dJNlkdGX9c0JIx6oLheIplfS6k+jg==
-----END CERTIFICATE-----
issuer=
commonName = SubMan V4.2 CI Google Pixel
organizationName = Giesecke and Devrient GmbH
organizationalUnitName = Mobile Security
countryName = DE
notBefore=2017-05-10 00:00:00Z
notAfter=2027-05-10 00:00:00Z
-----BEGIN CERTIFICATE-----
MIICaTCCAg6gAwIBAgICASwwCgYIKoZIzj0EAwIwczElMCMGA1UEAxMcIFN1Yk1h
biBWNC4yIENJIEdvb2dsZSBQaXhlbDEjMCEGA1UEChMaR2llc2Vja2UgYW5kIERl
dnJpZW50IEdtYkgxGDAWBgNVBAsTD01vYmlsZSBTZWN1cml0eTELMAkGA1UEBhMC
REUwHhcNMTcwNTEwMDAwMDAwWhcNMjcwNTEwMDAwMDAwWjBzMSUwIwYDVQQDExwg
U3ViTWFuIFY0LjIgQ0kgR29vZ2xlIFBpeGVsMSMwIQYDVQQKExpHaWVzZWNrZSBh
bmQgRGV2cmllbnQgR21iSDEYMBYGA1UECxMPTW9iaWxlIFNlY3VyaXR5MQswCQYD
VQQGEwJERTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHNorfaJsGzqWNawyAhl
IAv9QL2/+b9RsUoso06t/dKX1MRr5CUJ51acvv5TAFhQKIml+dwLbFnV5aO+8W6Z
wxajgZEwgY4wHwYDVR0jBBgwFoAUtg8LiX/WMLiM/tYWH46oCMU4KsMwHQYDVR0O
BBYEFLYPC4l/1jC4jP7WFh+OqAjFOCrDMA4GA1UdDwEB/wQEAwIBBjAXBgNVHSAB
Af8EDTALMAkGB2eBEgECAQAwDwYDVR0TAQH/BAUwAwEB/zASBgNVHREECzAJiAcr
BgEEAdwPMAoGCCqGSM49BAMCA0kAMEYCIQDpoZcuAQrjATW8U+AWqMUJ0dY6nWW1
R1QmFzVZ1yMXSwIhALCvRqkCtgiavdeFeSgsSNbY5Fhd+QoCltuSh1U4TE7A
-----END CERTIFICATE-----
issuer=
countryName = DE
commonName = SubMan V4.2 CI
organizationName = Giesecke and Devrient
organizationalUnitName = Mobile Security
notBefore=2016-08-12 13:51:48Z
notAfter=2026-08-12 13:51:48Z
-----BEGIN CERTIFICATE-----
MIICUjCCAfigAwIBAgIDQgAAMAoGCCqGSM49BAMCMGAxCzAJBgNVBAYTAkRFMRcw
FQYDVQQDEw5TdWJNYW4gVjQuMiBDSTEeMBwGA1UEChMVR2llc2Vja2UgYW5kIERl
dnJpZW50MRgwFgYDVQQLEw9Nb2JpbGUgU2VjdXJpdHkwHhcNMTYwODEyMTM1MTQ4
WhcNMjYwODEyMTM1MTQ4WjBgMQswCQYDVQQGEwJERTEXMBUGA1UEAxMOU3ViTWFu
IFY0LjIgQ0kxHjAcBgNVBAoTFUdpZXNlY2tlIGFuZCBEZXZyaWVudDEYMBYGA1UE
CxMPTW9iaWxlIFNlY3VyaXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYIgl
VQr9wbXOlwPp8qMg5Df08Cli9Mc+lpr3Lwa9PlVA3QWlLeX4GfD4H3phLBqVIa17
yHttmtheTxi0KoEqhKOBoDCBnTAdBgNVHQ4EFgQU6lOt7zMpuVCa/XVf1Ei4LcG8
7P8wDgYDVR0PAQH/BAQDAgEGMBcGA1UdIAEB/wQNMAswCQYHZ4ESAQIBADAPBgNV
HRMBAf8EBTADAQH/MBIGA1UdEQQLMAmIBysGAQQB3A8wLgYDVR0fBCcwJTAjoCGg
H4YdaHR0cDovL2dpLWRlLmNvbS90ZXN0LmNybC5wZW0wCgYIKoZIzj0EAwIDSAAw
RQIhAMMx2L/VHDiOW+Fl/OuFmhCdizYM17Yn9zAVieKO2T0iAiANWtCMmY+DzkqK
yHxBFX0U2tBd682zP4DpgRt8j3Ylew==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,740 @@
import math
import os
import sys
import subprocess
import time
import tempfile
from enum import IntEnum
from functools import cached_property, lru_cache
from pathlib import Path
from iqpilot.cereal import log
from iqpilot.common.utils import sudo_read, sudo_write
from iqpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action
from iqpilot.system.hardware.base import HardwareBase, LPABase, ThermalConfig, ThermalZone
from iqpilot.system.hardware.tici import iwlist
from iqpilot.system.hardware.tici.lpa import TiciLPA
from iqpilot.system.hardware.tici.pins import GPIO
from iqpilot.system.hardware.tici.amplifier import Amplifier
NM = 'org.freedesktop.NetworkManager'
NM_CON_ACT = NM + '.Connection.Active'
NM_DEV = NM + '.Device'
NM_DEV_WL = NM + '.Device.Wireless'
NM_DEV_STATS = NM + '.Device.Statistics'
NM_AP = NM + '.AccessPoint'
DBUS_PROPS = 'org.freedesktop.DBus.Properties'
MM = 'org.freedesktop.ModemManager1'
MM_MODEM = MM + ".Modem"
MM_MODEM_SIMPLE = MM + ".Modem.Simple"
MM_SIM = MM + ".Sim"
class MM_MODEM_STATE(IntEnum):
FAILED = -1
UNKNOWN = 0
INITIALIZING = 1
LOCKED = 2
DISABLED = 3
DISABLING = 4
ENABLING = 5
ENABLED = 6
SEARCHING = 7
REGISTERED = 8
DISCONNECTING = 9
CONNECTING = 10
CONNECTED = 11
class NMActiveConnectionState(IntEnum):
UNKNOWN = 0
ACTIVATING = 1
ACTIVATED = 2
DEACTIVATING = 3
DEACTIVATED = 4
class NMMetered(IntEnum):
NM_METERED_UNKNOWN = 0
NM_METERED_YES = 1
NM_METERED_NO = 2
NM_METERED_GUESS_YES = 3
NM_METERED_GUESS_NO = 4
TIMEOUT = 0.1
REFRESH_RATE_MS = 1000
NetworkType = log.DeviceState.NetworkType
NetworkStrength = log.DeviceState.NetworkStrength
# https://developer.gnome.org/ModemManager/unstable/ModemManager-Flags-and-Enumerations.html#MMModemAccessTechnology
MM_MODEM_ACCESS_TECHNOLOGY_UMTS = 1 << 5
MM_MODEM_ACCESS_TECHNOLOGY_LTE = 1 << 14
# MMModemStateFailedReason
MM_MODEM_STATE_FAILED_REASON_SIM_MISSING = 2
def affine_irq(val, action):
irqs = get_irqs_for_action(action)
if len(irqs) == 0:
return
for i in irqs:
sudo_write(str(val), f"/proc/irq/{i}/smp_affinity_list")
@lru_cache
def get_device_type():
# lru_cache and cache can cause memory leaks when used in classes
try:
with open("/sys/firmware/devicetree/base/model") as f:
model = f.read().strip('\x00')
except FileNotFoundError:
# off-device (e.g. the prebuilt build container fakes /TICI but has no
# devicetree); import must not crash. Not a real device type.
return "unknown"
return model.split('comma ')[-1]
class Tici(HardwareBase):
@staticmethod
def _ensure_system_python_path() -> None:
system_site = "/usr/lib/python3/dist-packages"
if system_site not in sys.path and os.path.isdir(system_site):
sys.path.append(system_site)
@staticmethod
def _run_direct_modem_command(command: str) -> None:
import serial
last_error: Exception | None = None
for device in ("/dev/ttyUSB2", "/dev/ttyUSB3"):
if not os.path.exists(device):
continue
try:
with serial.Serial(device, baudrate=9600, timeout=2) as modem:
modem.reset_input_buffer()
modem.write((command + "\r").encode("ascii"))
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
line = modem.readline().decode(errors="ignore").strip()
if not line:
continue
if line == "OK":
return
if line == "ERROR" or "ERROR" in line:
raise RuntimeError(f"{device}: {line}")
raise TimeoutError(f"{device}: timed out waiting for modem response")
except Exception as e:
last_error = e
if last_error is not None:
raise last_error
raise RuntimeError("No modem AT port available")
@cached_property
def bus(self):
try:
import dbus
except ModuleNotFoundError:
self._ensure_system_python_path()
import dbus
return dbus.SystemBus()
@cached_property
def nm(self):
return self.bus.get_object(NM, '/org/freedesktop/NetworkManager')
@property # this should not be cached, in case the modemmanager restarts
def mm(self):
return self.bus.get_object(MM, '/org/freedesktop/ModemManager1')
@cached_property
def amplifier(self):
if self.get_device_type() == "mici":
return None
if os.path.exists('/tmp/lite_hw') or os.environ.get('LITE') == '1':
return None
return Amplifier()
def get_os_version(self):
with open("/VERSION") as f:
return f.read().strip()
def get_device_type(self):
return get_device_type()
def reboot(self, reason=None):
subprocess.check_output(["sudo", "reboot"])
def uninstall(self):
Path("/data/__system_reset__").touch()
os.sync()
self.reboot()
def get_serial(self):
return self.get_cmdline()['androidboot.serialno']
def get_voltage(self):
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
return int(f.read())
def get_current(self):
with open("/sys/class/hwmon/hwmon1/curr1_input") as f:
return int(f.read())
def set_ir_power(self, percent: int):
if self.get_device_type() in ("tici", "tizi"):
return
value = int((percent / 100) * 300)
with open("/sys/class/leds/led:switch_2/brightness", "w") as f:
f.write("0\n")
with open("/sys/class/leds/led:torch_2/brightness", "w") as f:
f.write(f"{value}\n")
with open("/sys/class/leds/led:switch_2/brightness", "w") as f:
f.write(f"{value}\n")
def get_network_type(self):
try:
primary_connection = self.nm.Get(NM, 'PrimaryConnection', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
primary_connection = self.bus.get_object(NM, primary_connection)
primary_type = primary_connection.Get(NM_CON_ACT, 'Type', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if primary_type == '802-3-ethernet':
return NetworkType.ethernet
elif primary_type == '802-11-wireless':
return NetworkType.wifi
else:
active_connections = self.nm.Get(NM, 'ActiveConnections', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
for conn in active_connections:
c = self.bus.get_object(NM, conn)
tp = c.Get(NM_CON_ACT, 'Type', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if tp == 'gsm':
modem = self.get_modem()
modem_state = modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if modem_state < MM_MODEM_STATE.REGISTERED:
return NetworkType.none
access_t = modem.Get(MM_MODEM, 'AccessTechnologies', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if access_t >= MM_MODEM_ACCESS_TECHNOLOGY_LTE:
return NetworkType.cell4G
elif access_t >= MM_MODEM_ACCESS_TECHNOLOGY_UMTS:
return NetworkType.cell3G
else:
return NetworkType.cell2G
except Exception:
pass
return NetworkType.none
def get_modem(self):
objects = self.mm.GetManagedObjects(dbus_interface="org.freedesktop.DBus.ObjectManager", timeout=TIMEOUT)
if not objects:
raise RuntimeError("ModemManager returned no modems")
modem_path = next(iter(objects))
return self.bus.get_object(MM, modem_path)
def get_wlan(self):
wlan_path = self.nm.GetDeviceByIpIface('wlan0', dbus_interface=NM, timeout=TIMEOUT)
return self.bus.get_object(NM, wlan_path)
def get_wwan(self):
wwan_path = self.nm.GetDeviceByIpIface('wwan0', dbus_interface=NM, timeout=TIMEOUT)
return self.bus.get_object(NM, wwan_path)
def get_sim_info(self):
modem = self.get_modem()
sim_path = modem.Get(MM_MODEM, 'Sim', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if sim_path == "/":
return {
'sim_id': '',
'mcc_mnc': None,
'network_type': ["Unknown"],
'sim_state': ["ABSENT"],
'data_connected': False
}
else:
sim = self.bus.get_object(MM, sim_path)
return {
'sim_id': str(sim.Get(MM_SIM, 'SimIdentifier', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)),
'mcc_mnc': str(sim.Get(MM_SIM, 'OperatorIdentifier', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)),
'network_type': ["Unknown"],
'sim_state': ["READY"],
'data_connected': modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT) == MM_MODEM_STATE.CONNECTED,
}
def get_sim_lpa(self) -> LPABase:
return TiciLPA()
def get_imei(self, slot):
if slot != 0:
return ""
return str(self.get_modem().Get(MM_MODEM, 'EquipmentIdentifier', dbus_interface=DBUS_PROPS, timeout=TIMEOUT))
def get_network_info(self):
if self.get_device_type() == "mici":
return None
try:
modem = self.get_modem()
info = modem.Command("AT+QNWINFO", math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
extra = modem.Command('AT+QENG="servingcell"', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
state = modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
except Exception:
return None
if info and info.startswith('+QNWINFO: '):
info = info.replace('+QNWINFO: ', '').replace('"', '').split(',')
extra = "" if extra is None else extra.replace('+QENG: "servingcell",', '').replace('"', '')
state = "" if state is None else MM_MODEM_STATE(state).name
if len(info) != 4:
return None
technology, operator, band, channel = info
return({
'technology': technology,
'operator': operator,
'band': band,
'channel': int(channel),
'extra': extra,
'state': state,
})
else:
return None
def parse_strength(self, percentage):
if percentage < 25:
return NetworkStrength.poor
elif percentage < 50:
return NetworkStrength.moderate
elif percentage < 75:
return NetworkStrength.good
else:
return NetworkStrength.great
def get_network_strength(self, network_type):
network_strength = NetworkStrength.unknown
try:
if network_type == NetworkType.none:
pass
elif network_type == NetworkType.wifi:
wlan = self.get_wlan()
active_ap_path = wlan.Get(NM_DEV_WL, 'ActiveAccessPoint', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if active_ap_path != "/":
active_ap = self.bus.get_object(NM, active_ap_path)
strength = int(active_ap.Get(NM_AP, 'Strength', dbus_interface=DBUS_PROPS, timeout=TIMEOUT))
network_strength = self.parse_strength(strength)
else: # Cellular
modem = self.get_modem()
strength = int(modem.Get(MM_MODEM, 'SignalQuality', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)[0])
network_strength = self.parse_strength(strength)
except Exception:
pass
return network_strength
def get_network_metered(self, network_type) -> bool:
try:
primary_connection = self.nm.Get(NM, 'PrimaryConnection', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
primary_connection = self.bus.get_object(NM, primary_connection)
primary_devices = primary_connection.Get(NM_CON_ACT, 'Devices', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
for dev in primary_devices:
dev_obj = self.bus.get_object(NM, str(dev))
metered_prop = dev_obj.Get(NM_DEV, 'Metered', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if network_type == NetworkType.wifi:
if metered_prop in [NMMetered.NM_METERED_YES, NMMetered.NM_METERED_GUESS_YES]:
return True
elif network_type in [NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G]:
if metered_prop == NMMetered.NM_METERED_NO:
return False
except Exception:
pass
return super().get_network_metered(network_type)
def get_modem_version(self):
try:
modem = self.get_modem()
return modem.Get(MM_MODEM, 'Revision', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
except Exception:
return None
def get_modem_temperatures(self):
timeout = 0.2 # Default timeout is too short
try:
modem = self.get_modem()
temps = modem.Command("AT+QTEMP", math.ceil(timeout), dbus_interface=MM_MODEM, timeout=timeout)
return list(filter(lambda t: t != 255, map(int, temps.split(' ')[1].split(','))))
except Exception:
return []
def get_current_power_draw(self):
return (self.read_param_file("/sys/class/hwmon/hwmon1/power1_input", int) / 1e6)
def get_som_power_draw(self):
return (self.read_param_file("/sys/class/power_supply/bms/voltage_now", int) * self.read_param_file("/sys/class/power_supply/bms/current_now", int) / 1e12)
def shutdown(self):
os.system("sudo poweroff")
def get_thermal_config(self):
intake, exhaust, case = None, None, None
if self.get_device_type() == "mici":
case = ThermalZone("case")
intake = ThermalZone("intake")
exhaust = ThermalZone("exhaust")
return ThermalConfig(cpu=[ThermalZone(f"cpu{i}-silver-usr") for i in range(4)] +
[ThermalZone(f"cpu{i}-gold-usr") for i in range(4)],
gpu=[ThermalZone("gpu0-usr"), ThermalZone("gpu1-usr")],
dsp=ThermalZone("compute-hvx-usr"),
memory=ThermalZone("ddr-usr"),
pmic=[ThermalZone("pm8998_tz"), ThermalZone("pm8005_tz")],
intake=intake,
exhaust=exhaust,
case=case)
def set_display_power(self, on):
try:
with open("/sys/class/backlight/panel0-backlight/bl_power", "w") as f:
f.write("0" if on else "4")
except Exception:
pass
def set_screen_brightness(self, percentage):
try:
with open("/sys/class/backlight/panel0-backlight/max_brightness") as f:
max_brightness = float(f.read().strip())
val = int(percentage * (max_brightness / 100.))
with open("/sys/class/backlight/panel0-backlight/brightness", "w") as f:
f.write(str(val))
except Exception:
pass
def get_screen_brightness(self):
try:
with open("/sys/class/backlight/panel0-backlight/max_brightness") as f:
max_brightness = float(f.read().strip())
with open("/sys/class/backlight/panel0-backlight/brightness") as f:
return int(float(f.read()) / (max_brightness / 100.))
except Exception:
return 0
def set_power_save(self, powersave_enabled):
# amplifier, 100mW at idle
if self.amplifier is not None:
self.amplifier.set_global_shutdown(amp_disabled=powersave_enabled)
if not powersave_enabled:
self.amplifier.initialize_configuration(self.get_device_type())
# *** CPU config ***
# offline big cluster
for i in range(4, 8):
val = '0' if powersave_enabled else '1'
sudo_write(val, f'/sys/devices/system/cpu/cpu{i}/online')
for n in ('0', '4'):
if powersave_enabled and n == '4':
continue
gov = 'ondemand' if powersave_enabled else 'performance'
sudo_write(gov, f'/sys/devices/system/cpu/cpufreq/policy{n}/scaling_governor')
# *** IRQ config ***
# GPU, modeld core
affine_irq(7, "kgsl-3d0")
# camerad core
camera_irqs = ("a5", "cci", "cpas_camnoc", "cpas-cdm", "csid", "ife", "csid-lite", "ife-lite")
for n in camera_irqs:
affine_irq(6, n)
def get_gpu_usage_percent(self):
try:
with open('/sys/class/kgsl/kgsl-3d0/gpubusy') as f:
used, total = f.read().strip().split()
return 100.0 * int(used) / int(total)
except Exception:
return 0
def initialize_hardware(self):
if self.amplifier is not None:
self.amplifier.initialize_configuration(self.get_device_type())
# Allow hardwared to write engagement status to kmsg
os.system("sudo chmod a+w /dev/kmsg")
# Ensure fan gpio is enabled so fan runs until shutdown, also turned on at boot by the ABL
gpio_init(GPIO.SOM_ST_IO, True)
gpio_set(GPIO.SOM_ST_IO, 1)
# *** IRQ config ***
# mask off big cluster from default affinity
sudo_write("f", "/proc/irq/default_smp_affinity")
# move these off the default core
affine_irq(1, "msm_vidc") # encoders
affine_irq(1, "i2c_geni") # sensors
# *** GPU config ***
# https://github.com/commaai/agnos-kernel-sdm845/blob/master/arch/arm64/boot/dts/qcom/sdm845-gpu.dtsi#L216
affine_irq(5, "fts_ts") # touch
affine_irq(5, "msm_drm") # display
sudo_write("1", "/sys/class/kgsl/kgsl-3d0/min_pwrlevel")
sudo_write("1", "/sys/class/kgsl/kgsl-3d0/max_pwrlevel")
sudo_write("1", "/sys/class/kgsl/kgsl-3d0/force_bus_on")
sudo_write("1", "/sys/class/kgsl/kgsl-3d0/force_clk_on")
sudo_write("1", "/sys/class/kgsl/kgsl-3d0/force_rail_on")
sudo_write("1000", "/sys/class/kgsl/kgsl-3d0/idle_timer")
sudo_write("performance", "/sys/class/kgsl/kgsl-3d0/devfreq/governor")
sudo_write("710", "/sys/class/kgsl/kgsl-3d0/max_clock_mhz")
# setup governors
sudo_write("performance", "/sys/class/devfreq/soc:qcom,cpubw/governor")
sudo_write("performance", "/sys/class/devfreq/soc:qcom,memlat-cpu0/governor")
sudo_write("performance", "/sys/class/devfreq/soc:qcom,memlat-cpu4/governor")
# *** VIDC (encoder) config ***
sudo_write("N", "/sys/kernel/debug/msm_vidc/clock_scaling")
sudo_write("Y", "/sys/kernel/debug/msm_vidc/disable_thermal_mitigation")
# pandad core
affine_irq(3, "spi_geni") # SPI
if "tici" in self.get_device_type():
affine_irq(3, "xhci-hcd:usb3")
affine_irq(3, "xhci-hcd:usb1")
try:
pid = subprocess.check_output(["pgrep", "-f", "spi0"], encoding='utf8').strip()
subprocess.call(["sudo", "chrt", "-f", "-p", "1", pid])
subprocess.call(["sudo", "taskset", "-pc", "3", pid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessException as e:
print(str(e))
def configure_modem(self):
from iqpilot.common.params import Params
sim_info = self.get_sim_info()
sim_id = sim_info.get('sim_id', '')
params = Params()
manual_apn = params.get("GsmApn", encoding="utf-8") or ""
metered_enabled = params.get_bool("GsmMetered")
modem = self.get_modem()
try:
manufacturer = str(modem.Get(MM_MODEM, 'Manufacturer', dbus_interface=DBUS_PROPS, timeout=TIMEOUT))
except Exception:
manufacturer = None
cmds = []
is_comma_profile = self.get_sim_lpa().is_comma_profile(sim_id)
roaming_enabled = params.get_bool("GsmRoaming")
initial_eps_apn = "" if is_comma_profile else manual_apn
if not is_comma_profile and params.get("GsmRoaming") is None:
params.put_bool("GsmRoaming", True)
roaming_enabled = True
subprocess.call([
"nmcli", "connection", "modify", "lte",
"gsm.auto-config", "no" if manual_apn else "yes",
"gsm.apn", manual_apn,
"gsm.home-only", "no" if roaming_enabled else "yes",
"gsm.network-id", "",
"gsm.initial-eps-bearer-configure", "yes" if initial_eps_apn else "no",
"gsm.initial-eps-bearer-apn", initial_eps_apn,
"connection.metered", "unknown" if metered_enabled else "no",
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if self.get_device_type() in ("tici", "tizi"):
if initial_eps_apn:
subprocess.call(["mmcli", "-m", "any", f'--3gpp-set-initial-eps-bearer-settings=apn={initial_eps_apn}'])
else:
subprocess.call(["mmcli", "-m", "any", '--3gpp-set-initial-eps-bearer-settings=apn='])
cmds += [
# configure modem as data-centric
'AT+QNVW=5280,0,"0102000000000000"',
'AT+QNVFW="/nv/item_files/ims/IMS_enable",00',
'AT+QNVFW="/nv/item_files/modem/mmode/ue_usage_setting",01',
]
if self.get_device_type() == "tizi":
cmds += [
'AT+QSIMDET=1,0',
'AT+QSIMSTAT=1',
]
elif manufacturer == 'Cavli Inc.':
cmds += [
'AT^SIMSWAP=1', # use SIM slot, instead of internal eSIM
'AT$QCSIMSLEEP=0', # disable SIM sleep
'AT$QCSIMCFG=SimPowerSave,0', # more sleep disable
# ethernet config
'AT$QCPCFG=usbNet,0',
'AT$QCNETDEVCTL=3,1',
]
else:
# this modem gets upset with too many AT commands
if sim_id is None or len(sim_id) == 0:
cmds += [
# SIM sleep disable
'AT$QCSIMSLEEP=0',
'AT$QCSIMCFG=SimPowerSave,0',
# ethernet config
'AT$QCPCFG=usbNet,1',
]
for cmd in cmds:
try:
modem.Command(cmd, math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
except Exception:
pass
# eSIM prime
dest = "/etc/NetworkManager/system-connections/esim.nmconnection"
if self.get_sim_lpa().is_comma_profile(sim_id) and not os.path.exists(dest):
with open(Path(__file__).parent/'esim.nmconnection') as f, tempfile.NamedTemporaryFile(mode='w') as tf:
dat = f.read()
dat = dat.replace("sim-id=", f"sim-id={sim_id}")
tf.write(dat)
tf.flush()
# needs to be root
os.system(f"sudo cp {tf.name} {dest}")
os.system(f"sudo nmcli con load {dest}")
def recover_sim_detection(self) -> bool:
# A worn SIM-tray presence switch can read "removed" while the SIM pads still make
# contact; with hot-swap detect armed (AT+QSIMDET=1) the modem never powers the SIM
# and lands in failed/sim-missing. Disabling detect and rebooting the modem makes it
# probe the SIM electrically. Safe to retry on failure: firing disarms the QSIMDET
# gate, so a genuinely SIM-less device gets at most one extra modem reboot per boot.
if self.get_device_type() not in ("tici", "tizi"):
return False
try:
modem = self.get_modem()
state = modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if state != MM_MODEM_STATE.FAILED:
return False
reason = modem.Get(MM_MODEM, 'StateFailedReason', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if reason != MM_MODEM_STATE_FAILED_REASON_SIM_MISSING:
return False
detect = str(modem.Command('AT+QSIMDET?', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)).strip()
if not detect.startswith('+QSIMDET: 1'):
return False
modem.Command('AT+QSIMDET=0,0', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
modem.Command('AT+CFUN=1,1', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
return True
except Exception:
return False
def reboot_modem(self):
modem = None
try:
modem = self.get_modem()
except Exception:
pass
if modem is not None:
for state in (0, 1):
try:
modem.Command(f'AT+CFUN={state}', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
except Exception:
pass
return
for state in (0, 1):
try:
self._run_direct_modem_command(f"AT+CFUN={state}")
except Exception:
pass
def get_networks(self):
r = {}
wlan = iwlist.scan()
if wlan is not None:
r['wlan'] = wlan
lte_info = self.get_network_info()
if lte_info is not None:
extra = lte_info['extra']
# <state>,"LTE",<is_tdd>,<mcc>,<mnc>,<cellid>,<pcid>,<earfcn>,<freq_band_ind>,
# <ul_bandwidth>,<dl_bandwidth>,<tac>,<rsrp>,<rsrq>,<rssi>,<sinr>,<srxlev>
if 'LTE' in extra:
extra = extra.split(',')
try:
r['lte'] = [{
"mcc": int(extra[3]),
"mnc": int(extra[4]),
"cid": int(extra[5], 16),
"nmr": [{"pci": int(extra[6]), "earfcn": int(extra[7])}],
}]
except (ValueError, IndexError):
pass
return r
def get_modem_data_usage(self):
try:
wwan = self.get_wwan()
# Ensure refresh rate is set so values don't go stale
refresh_rate = wwan.Get(NM_DEV_STATS, 'RefreshRateMs', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if refresh_rate != REFRESH_RATE_MS:
u = type(refresh_rate)
wwan.Set(NM_DEV_STATS, 'RefreshRateMs', u(REFRESH_RATE_MS), dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
tx = wwan.Get(NM_DEV_STATS, 'TxBytes', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
rx = wwan.Get(NM_DEV_STATS, 'RxBytes', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
return int(tx), int(rx)
except Exception:
return -1, -1
def has_internal_panda(self):
return True
def reset_internal_panda(self):
gpio_init(GPIO.STM_RST_N, True)
gpio_init(GPIO.STM_BOOT0, True)
gpio_set(GPIO.STM_RST_N, 1)
gpio_set(GPIO.STM_BOOT0, 0)
time.sleep(1)
gpio_set(GPIO.STM_RST_N, 0)
def recover_internal_panda(self):
gpio_init(GPIO.STM_RST_N, True)
gpio_init(GPIO.STM_BOOT0, True)
gpio_set(GPIO.STM_RST_N, 1)
gpio_set(GPIO.STM_BOOT0, 1)
time.sleep(0.5)
gpio_set(GPIO.STM_RST_N, 0)
time.sleep(0.5)
gpio_set(GPIO.STM_BOOT0, 0)
def booted(self):
# this normally boots within 8s, but on rare occasions takes 30+s
encoder_state = sudo_read("/sys/kernel/debug/msm_vidc/core0/info")
if "Core state: 0" in encoder_state and (time.monotonic() < 60*2):
return False
return True
if __name__ == "__main__":
t = Tici()
t.configure_modem()
t.initialize_hardware()
t.set_power_save(False)
print(t.get_sim_info())

View File

@@ -0,0 +1,28 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC+iXXq30Tq+J5N
Kat3KWHCzcmwZ55nGh6WggAqECa5CasBlM9VeROpVu3beA+5h0MibRgbD4DMtVXB
t6gEvZ8nd04E7eLA9LTZyFDZ7SkSOVj4oXOQsT0GnJmKrASW5KslTWqVzTfo2XCt
Z+004ikLxmyFeBO8NOcErW1pa8gFdQDToH9FrA7kgysic/XVESTOoe7XlzRoe/eZ
acEQ+jtnmFd21A4aEADkk00Ahjr0uKaJiLUAPatxs2icIXWpgYtfqqtaKF23wSt6
1OTu6cAwXbOWr3m+IUSRUO0IRzEIQS3z1jfd1svgzSgSSwZ1Lhj4AoKxIEAIc8qJ
rO4uymCJAgMBAAECggEBAISFevxHGdoL3Z5xkw6oO5SQKO2GxEeVhRzNgmu/HA+q
x8OryqD6O1CWY4037kft6iWxlwiLOdwna2P25ueVM3LxqdQH2KS4DmlCx+kq6FwC
gv063fQPMhC9LpWimvaQSPEC7VUPjQlo4tPY6sTTYBUOh0A1ihRm/x7juKuQCWix
Cq8C/DVnB1X4mGj+W3nJc5TwVJtgJbbiBrq6PWrhvB/3qmkxHRL7dU2SBb2iNRF1
LLY30dJx/cD73UDKNHrlrsjk3UJc29Mp4/MladKvUkRqNwlYxSuAtJV0nZ3+iFkL
s3adSTHdJpClQer45R51rFDlVsDz2ZBpb/hRNRoGDuECgYEA6A1EixLq7QYOh3cb
Xhyh3W4kpVvA/FPfKH1OMy3ONOD/Y9Oa+M/wthW1wSoRL2n+uuIW5OAhTIvIEivj
6bAZsTT3twrvOrvYu9rx9aln4p8BhyvdjeW4kS7T8FP5ol6LoOt2sTP3T1LOuJPO
uQvOjlKPKIMh3c3RFNWTnGzMPa0CgYEA0jNiPLxP3A2nrX0keKDI+VHuvOY88gdh
0W5BuLMLovOIDk9aQFIbBbMuW1OTjHKv9NK+Lrw+YbCFqOGf1dU/UN5gSyE8lX/Q
FsUGUqUZx574nJZnOIcy3ONOnQLcvHAQToLFAGUd7PWgP3CtHkt9hEv2koUwL4vo
ikTP1u9Gkc0CgYEA2apoWxPZrY963XLKBxNQecYxNbLFaWq67t3rFnKm9E8BAICi
4zUaE5J1tMVi7Vi9iks9Ml9SnNyZRQJKfQ+kaebHXbkyAaPmfv+26rqHKboA0uxA
nDOZVwXX45zBkp6g1sdHxJx8JLoGEnkC9eyvSi0C//tRLx86OhLErXwYcNkCf1it
VMRKrWYoXJTUNo6tRhvodM88UnnIo3u3CALjhgU4uC1RTMHV4ZCGBwiAOb8GozSl
s5YD1E1iKwEULloHnK6BIh6P5v8q7J6uf/xdqoKMjlWBHgq6/roxKvkSPA1DOZ3l
jTadcgKFnRUmc+JT9p/ZbCxkA/ALFg8++G+0ghECgYA8vG3M/utweLvq4RI7l7U7
b+i2BajfK2OmzNi/xugfeLjY6k2tfQGRuv6ppTjehtji2uvgDWkgjJUgPfZpir3I
RsVMUiFgloWGHETOy0Qvc5AwtqTJFLTD1Wza2uBilSVIEsg6Y83Gickh+ejOmEsY
6co17RFaAZHwGfCFFjO76Q==
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,35 @@
import subprocess
def scan(interface="wlan0"):
result = []
try:
r = subprocess.check_output(["iwlist", interface, "scan"], encoding='utf8')
mac = None
for line in r.split('\n'):
if "Address" in line:
# Based on the adapter eithere a percentage or dBm is returned
# Add previous network in case no dBm signal level was seen
if mac is not None:
result.append({"mac": mac})
mac = None
mac = line.split(' ')[-1]
elif "dBm" in line:
try:
level = line.split('Signal level=')[1]
rss = int(level.split(' ')[0])
result.append({"mac": mac, "rss": rss})
mac = None
except ValueError:
continue
# Add last network if no dBm was found
if mac is not None:
result.append({"mac": mac})
return result
except Exception:
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
# GPIO pin definitions
class GPIO:
# both GPIO_STM_RST_N and GPIO_LTE_RST_N are misnamed, they are high to reset
HUB_RST_N = 30
UBLOX_RST_N = 32
UBLOX_SAFEBOOT_N = 33
GNSS_PWR_EN = 34 # SCHEMATIC LABEL: GPIO_UBLOX_PWR_EN
STM_RST_N = 124
STM_BOOT0 = 134
STM_PWR_EN_N = 41 # because STM32H7 RST doesn't generate a full power-on-reset
SIREN = 42
SOM_ST_IO = 49
LTE_RST_N = 50
LTE_PWRKEY = 116
LTE_BOOT = 52
# GPIO_CAM0_DVDD_EN = /sys/kernel/debug/regulator/camera_rear_ldo
CAM0_AVDD_EN = 8
CAM0_RSTN = 9
CAM1_RSTN = 7
CAM2_RSTN = 12
# Sensor interrupts
BMX055_ACCEL_INT = 21
BMX055_GYRO_INT = 23
BMX055_MAGN_INT = 87
LSM_INT = 84

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import sys
import time
import datetime
import numpy as np
from collections import deque
from iqpilot.common.realtime import Ratekeeper
from iqpilot.common.filter_simple import FirstOrderFilter
def read_power():
with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f:
return int(f.read()) / 1e6
def sample_power(seconds=5) -> list[float]:
rate = 123
rk = Ratekeeper(rate, print_delay_threshold=None)
pwrs = []
for _ in range(rate*seconds):
pwrs.append(read_power())
rk.keep_time()
return pwrs
def get_power(seconds=5):
pwrs = sample_power(seconds)
return np.mean(pwrs)
def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout):
start_time = time.monotonic()
pwrs = deque([min_pwr - 1.]*min_secs_in_range, maxlen=min_secs_in_range)
while (time.monotonic() - start_time < timeout):
pwrs.append(get_power(1))
if all(min_pwr <= p <= max_pwr for p in pwrs):
break
return np.mean(pwrs)
if __name__ == "__main__":
duration = None
if len(sys.argv) > 1:
duration = int(sys.argv[1])
rate = 23
rk = Ratekeeper(rate, print_delay_threshold=None)
fltr = FirstOrderFilter(0, 5, 1. / rate, initialized=False)
measurements = []
start_time = time.monotonic()
try:
while duration is None or time.monotonic() - start_time < duration:
fltr.update(read_power())
if rk.frame % rate == 0:
measurements.append(fltr.x)
t = datetime.timedelta(seconds=time.monotonic() - start_time)
avg = sum(measurements) / len(measurements)
print(f"Now: {fltr.x:.2f} W, Avg: {avg:.2f} W over {t}")
rk.keep_time()
except KeyboardInterrupt:
pass
t = datetime.timedelta(seconds=time.monotonic() - start_time)
avg = sum(measurements) / len(measurements)
print(f"\nAverage power: {avg:.2f}W over {t}")

View File

@@ -0,0 +1,145 @@
import ctypes
import hashlib
import os
import subprocess
from pathlib import Path
import numpy as np
try:
from pyzbar.pyzbar import decode as _pyzbar_decode
except Exception:
_pyzbar_decode = None
ROOT = Path(__file__).resolve().parents[3]
QUIRC_LIB_DIR = ROOT / "third_party" / "quirc" / "lib"
HELPER_C = Path(__file__).with_name("qr_decode_quirc.c")
BUILD_DIR = ROOT / ".run" / "cache" / "esim_qr"
SO_PATH = BUILD_DIR / "libiqpilot_quirc_decode.so"
_LIB: ctypes.CDLL | None = None
def _build_decoder() -> bool:
BUILD_DIR.mkdir(parents=True, exist_ok=True)
cmd = [
os.environ.get("CC", "cc"),
"-O2",
"-shared",
"-fPIC",
str(HELPER_C),
str(QUIRC_LIB_DIR / "quirc.c"),
str(QUIRC_LIB_DIR / "identify.c"),
str(QUIRC_LIB_DIR / "decode.c"),
str(QUIRC_LIB_DIR / "version_db.c"),
"-I",
str(QUIRC_LIB_DIR),
"-o",
str(SO_PATH),
]
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
return True
except Exception:
return False
def _load_decoder() -> ctypes.CDLL | None:
global _LIB
if _LIB is not None:
return _LIB
if not SO_PATH.exists():
if not _build_decoder():
return None
try:
lib = ctypes.CDLL(str(SO_PATH))
lib.iqpilot_decode_qr_gray.argtypes = [
ctypes.POINTER(ctypes.c_uint8),
ctypes.c_int,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
]
lib.iqpilot_decode_qr_gray.restype = ctypes.c_int
_LIB = lib
return _LIB
except Exception:
return None
def decode_qr(image: bytes | np.ndarray, width: int | None = None, height: int | None = None) -> list[str]:
"""
Decode QR payloads from a grayscale image.
Accepts:
- ndarray shape (H, W), uint8
- bytes + explicit width/height
"""
arr: np.ndarray
if isinstance(image, np.ndarray):
if image.ndim != 2:
raise ValueError("decode_qr expects grayscale ndarray with shape (H, W)")
arr = np.ascontiguousarray(image, dtype=np.uint8)
h, w = arr.shape
else:
if width is None or height is None:
raise ValueError("width and height are required when passing raw bytes")
arr = np.frombuffer(image, dtype=np.uint8).reshape((height, width))
arr = np.ascontiguousarray(arr)
h, w = arr.shape
if _pyzbar_decode is not None:
try:
pyzbar_results = _pyzbar_decode(arr)
payloads = []
for result in pyzbar_results:
payload = result.data.decode("utf-8", errors="ignore").strip()
if payload:
payloads.append(payload)
if payloads:
return payloads
except Exception:
pass
lib = _load_decoder()
if lib is None:
return []
out_size = 8192
out_buf = ctypes.create_string_buffer(out_size)
count = lib.iqpilot_decode_qr_gray(
arr.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)),
int(w),
int(h),
out_buf,
out_size,
)
if count <= 0:
return []
raw = out_buf.value.decode("utf-8", errors="ignore")
return [line.strip() for line in raw.splitlines() if line.strip()]
def validate_lpa_activation_code(payload: str) -> tuple[bool, str]:
if not payload.startswith("LPA:"):
return False, "QR does not contain an LPA activation code"
parts = payload[4:].split("$")
if len(parts) != 3:
return False, "Invalid LPA format"
version, smdp, matching = [p.strip() for p in parts]
if version != "1":
return False, "Unsupported LPA version"
if len(smdp) == 0 or "." not in smdp:
return False, "Invalid SM-DP+ address"
if len(matching) == 0:
return False, "Missing matching ID"
return True, ""
def stable_code_key(payload: str) -> str:
return hashlib.sha256(payload.encode("utf-8")).hexdigest()

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
#nmcli connection modify --temporary lte gsm.home-only yes
#nmcli connection modify --temporary lte gsm.auto-config yes
#nmcli connection modify --temporary lte connection.autoconnect-retries 20
sudo nmcli connection reload
sudo systemctl stop ModemManager
nmcli con down lte
nmcli con down blue-prime
# power cycle modem
/usr/comma/lte/lte.sh stop_blocking
/usr/comma/lte/lte.sh start
sudo systemctl restart NetworkManager
#sudo systemctl restart ModemManager
sudo ModemManager --debug

View File

@@ -0,0 +1,233 @@
#!/bin/bash
# USB mass-storage gadget exposing a snapshot of /data/media/0/realdata (dashcam clips + logs)
# over the same configfs gadget mechanism as /usr/comma/set_adb.sh. openpilot keeps running; the
# export is a read-only snapshot built at enable time, not a live view of realdata.
#
# The device only has one physical USB controller (UDC), so ADB and USB storage must live in the
# SAME composite gadget (/config/usb_gadget/g1) rather than each owning their own. Earlier versions
# of this script called /usr/comma/set_adb.sh as a black box and then unbound/rebound around it,
# but that intermediate bind/unbind churn made the *next* bind flaky (functionfs needs its
# userspace side, adbd, settled before the gadget can (re)bind). So instead we replicate set_adb.sh's
# handful of setup lines directly here and do exactly one bind at the end, covering whichever
# functions (ADB, mass storage) are currently enabled.
#
# Without composing like this, comma's adb-param-watcher systemd unit (which fires whenever
# /data/params/d/AdbEnabled is touched, even to the same value) would rebuild g1 with only its own
# functions and silently drop ours.
set -e
# serialize invocations: rapid toggling can otherwise race on the same /config/usb_gadget/g1 tree
# and leave it in a half-built state
LOCKFILE="/tmp/set_usb_storage.lock"
exec 9>"$LOCKFILE"
flock 9
IMG="/data/media/0/usb_storage.img"
LOOP_MNT="/tmp/usb_storage_mnt"
REALDATA="/data/media/0/realdata"
UDC_NAME="a600000.dwc3"
GADGET="/config/usb_gadget/g1"
SAFETY_MARGIN_KB=$((2 * 1024 * 1024)) # keep 2GB free on /data after the image
CAP_KB=$((4 * 1024 * 1024)) # never build more than a 4GB snapshot (FAT32 + dir overhead eats into this)
build_image() {
avail_kb=$(df --output=avail -k /data | tail -1)
budget_kb=$((avail_kb - SAFETY_MARGIN_KB))
if [ "$budget_kb" -gt "$CAP_KB" ]; then
budget_kb=$CAP_KB
fi
if [ "$budget_kb" -lt $((512 * 1024)) ]; then
echo "Not enough free space on /data to build a USB storage snapshot" >&2
exit 1
fi
echo "Building ${budget_kb}KB FAT32 snapshot image at $IMG"
sudo rm -f "$IMG"
sudo fallocate -l "${budget_kb}K" "$IMG" || sudo dd if=/dev/zero of="$IMG" bs=1M count=$((budget_kb / 1024))
sudo mkfs.vfat -F 32 -n IQPILOT "$IMG"
sudo mkdir -p "$LOOP_MNT"
LOOP_DEV=$(sudo losetup -f)
sudo losetup "$LOOP_DEV" "$IMG"
sudo mount -t vfat "$LOOP_DEV" "$LOOP_MNT"
# select the most recent files up to budget, then copy them in one rsync
# pass (this script already runs as root, and one process beats thousands
# of per-file forked sudo/mkdir/cp calls, which was previously the actual
# bottleneck, not disk throughput).
copy_budget_kb=$((budget_kb * 90 / 100))
filelist=$(mktemp)
find "$REALDATA" -type f -printf '%T@ %s %P\n' 2>/dev/null | sort -rn | awk -v budget="$copy_budget_kb" '
{ used += int(($2 + 1023) / 1024); if (used > budget) { exit } print $3 }
' > "$filelist"
mkdir -p "$LOOP_MNT/realdata"
# FAT32 has no concept of unix owner/group/perms, so don't ask rsync to preserve them
rsync -rt --files-from="$filelist" "$REALDATA/" "$LOOP_MNT/realdata/"
echo "Copied $(wc -l < "$filelist") files into snapshot"
rm -f "$filelist"
sudo umount "$LOOP_MNT"
sudo losetup -d "$LOOP_DEV"
}
unbind() {
if [ -d "$GADGET" ]; then
cd "$GADGET"
echo "" | sudo tee UDC >/dev/null 2>&1 || true
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 ]` 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/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/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
# adbd needs a moment to open the ffs endpoint and negotiate descriptors before the gadget can bind
sleep 1
}
remove_adb() {
sudo systemctl stop adbd || true
if [ -d "$GADGET" ]; then
cd "$GADGET"
sudo rm -f configs/c.1/ffs.adb
sudo umount /dev/usb-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
}
add_mass_storage() {
# a function group that's ever been bound before can refuse attribute writes ("Device or
# resource busy") until it's torn down and recreated fresh, so always start from a clean slate
remove_mass_storage
cd "$GADGET"
sudo mkdir -p functions/mass_storage.0
echo 1 | sudo tee functions/mass_storage.0/stall >/dev/null
echo 1 | sudo tee functions/mass_storage.0/lun.0/removable >/dev/null
echo 1 | sudo tee functions/mass_storage.0/lun.0/ro >/dev/null
echo "$IMG" | sudo tee functions/mass_storage.0/lun.0/file >/dev/null
sudo rm -f configs/c.1/mass_storage.0
sudo ln -s functions/mass_storage.0 configs/c.1/
}
remove_mass_storage() {
if [ -d "$GADGET" ]; then
cd "$GADGET"
sudo rm -f configs/c.1/mass_storage.0
sudo rmdir functions/mass_storage.0 2>/dev/null || true
fi
}
bind() {
cd "$GADGET"
for attempt in $(seq 1 20); do
if echo "$UDC_NAME" | sudo tee UDC >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
echo "$UDC_NAME" | sudo tee UDC
}
read_bool_param() {
[ -f "$1" ] && [ "$(< "$1")" == "1" ]
}
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
remove_adb
fi
if [ "$USB_STORAGE_ENABLE" == "1" ]; then
echo "Enabling USB storage mode"
if [ ! -f "$IMG" ] || [ "$1" == "--rebuild" ]; then
build_image
fi
add_mass_storage
else
echo "Disabling USB storage mode"
remove_mass_storage
fi
bind

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import argparse
import collections
import multiprocessing
import os
import requests
from tqdm import tqdm
import iqpilot.system.hardware.tici.casync as casync
def get_chunk_download_size(chunk):
sha = chunk.sha.hex()
path = os.path.join(remote_url, sha[:4], sha + ".cacnk")
if os.path.isfile(path):
return os.path.getsize(path)
else:
r = requests.head(path, timeout=10)
r.raise_for_status()
return int(r.headers['content-length'])
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Compute overlap between two casync manifests')
parser.add_argument('frm')
parser.add_argument('to')
args = parser.parse_args()
frm = casync.parse_caibx(args.frm)
to = casync.parse_caibx(args.to)
remote_url = args.to.replace('.caibx', '')
most_common = collections.Counter(t.sha for t in to).most_common(1)[0][0]
frm_dict = casync.build_chunk_dict(frm)
# Get content-length for each chunk
with multiprocessing.Pool() as pool:
szs = list(tqdm(pool.imap(get_chunk_download_size, to), total=len(to)))
chunk_sizes = {t.sha: sz for (t, sz) in zip(to, szs, strict=True)}
sources: dict[str, list[int]] = {
'seed': [],
'remote_uncompressed': [],
'remote_compressed': [],
}
for chunk in to:
# Assume most common chunk is the zero chunk
if chunk.sha == most_common:
continue
if chunk.sha in frm_dict:
sources['seed'].append(chunk.length)
else:
sources['remote_uncompressed'].append(chunk.length)
sources['remote_compressed'].append(chunk_sizes[chunk.sha])
print()
print("Update statistics (excluding zeros)")
print()
print("Download only with no seed:")
print(f" Remote (uncompressed)\t\t{sum(sources['seed'] + sources['remote_uncompressed']) / 1000 / 1000:.2f} MB\tn = {len(to)}")
print(f" Remote (compressed download)\t{sum(chunk_sizes.values()) / 1000 / 1000:.2f} MB\tn = {len(to)}")
print()
print("Upgrade with seed partition:")
print(f" Seed (uncompressed)\t\t{sum(sources['seed']) / 1000 / 1000:.2f} MB\t\t\t\tn = {len(sources['seed'])}")
sz, n = sum(sources['remote_uncompressed']), len(sources['remote_uncompressed'])
print(f" Remote (uncompressed)\t\t{sz / 1000 / 1000:.2f} MB\t(avg {sz / 1000 / 1000 / n:4f} MB)\tn = {n}")
sz, n = sum(sources['remote_compressed']), len(sources['remote_compressed'])
print(f" Remote (compressed download)\t{sz / 1000 / 1000:.2f} MB\t(avg {sz / 1000 / 1000 / n:4f} MB)\tn = {n}")

View File

@@ -0,0 +1,37 @@
import json
import os
import requests
TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
MANIFESTS = [
os.path.join(TEST_DIR, "../agnos.json"),
os.path.join(TEST_DIR, "../agnos_tici_15_1.json"),
]
IMAGE_HOST = "git.konn3kt.com"
XZ_MAGIC = b"\xfd7zXZ\x00"
LFS_POINTER_MAGIC = b"version https://git-lfs"
class TestAgnosUpdater:
def test_manifest(self):
for manifest in MANIFESTS:
with open(manifest) as f:
m = json.load(f)
for img in m:
assert img['url'].split('/')[2] == IMAGE_HOST
if not img['sparse']:
assert img['hash'] == img['hash_raw']
s = requests.Session()
s.trust_env = False
r = s.get(img['url'], timeout=10, stream=True,
headers={"User-Agent": "IQOS-Updater"})
if r.status_code in (401, 403, 404):
continue
head = next(r.iter_content(chunk_size=256), b"") or b""
assert not head.startswith(XZ_MAGIC), f"{img['name']}: anonymous request served image content"
assert not head.startswith(LFS_POINTER_MAGIC), f"{img['name']}: anonymous request served the LFS pointer"

View File

@@ -0,0 +1,66 @@
import pytest
import time
import random
import subprocess
from panda import Panda
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.hardware.tici.hardware import Tici
from iqpilot.system.hardware.tici.amplifier import Amplifier
@pytest.mark.tici
class TestAmplifier:
def setup_method(self):
# clear dmesg
subprocess.check_call("sudo dmesg -C", shell=True)
HARDWARE.reset_internal_panda()
Panda.wait_for_panda(None, 30)
self.panda = Panda()
def teardown_method(self):
HARDWARE.reset_internal_panda()
def _check_for_i2c_errors(self, expected):
dmesg = subprocess.check_output("dmesg", shell=True, encoding='utf8')
i2c_lines = [l for l in dmesg.strip().splitlines() if 'i2c_geni a88000.i2c' in l]
i2c_str = '\n'.join(i2c_lines)
if not expected:
return len(i2c_lines) == 0
else:
return "i2c error :-107" in i2c_str or "Bus arbitration lost" in i2c_str
def test_init(self):
amp = Amplifier(debug=True)
r = amp.initialize_configuration(Tici().get_device_type())
assert r
assert self._check_for_i2c_errors(False)
def test_shutdown(self):
amp = Amplifier(debug=True)
for _ in range(10):
r = amp.set_global_shutdown(True)
r = amp.set_global_shutdown(False)
# amp config should be successful, with no i2c errors
assert r
assert self._check_for_i2c_errors(False)
def test_init_while_siren_play(self):
for _ in range(10):
self.panda.set_siren(False)
time.sleep(0.1)
self.panda.set_siren(True)
time.sleep(random.randint(0, 5))
amp = Amplifier(debug=True)
r = amp.initialize_configuration(Tici().get_device_type())
assert r
if self._check_for_i2c_errors(True):
break
else:
pytest.fail("didn't hit any i2c errors")

View File

@@ -0,0 +1,238 @@
import pytest
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.hardware.base import LPAError, LPAProfileNotFoundError, Profile
from iqpilot.system.hardware.tici import lpa as lpa_module
from iqpilot.system.hardware.tici.esim_manager import EsimManager
# https://euicc-manual.osmocom.org/docs/rsp/known-test-profile
# iccid is always the same for the given activation code
TEST_ACTIVATION_CODE = 'LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5'
TEST_ICCID = '8944476500001944011'
TEST_NICKNAME = 'test_profile'
def cleanup():
lpa = HARDWARE.get_sim_lpa()
try:
lpa.delete_profile(TEST_ICCID)
except LPAProfileNotFoundError:
pass
lpa.process_notifications()
@pytest.mark.tici
class TestEsim:
@classmethod
def setup_class(cls):
cleanup()
@classmethod
def teardown_class(cls):
cleanup()
def test_provision_enable_disable(self):
lpa = HARDWARE.get_sim_lpa()
current_active = lpa.get_active_profile()
lpa.download_profile(TEST_ACTIVATION_CODE, TEST_NICKNAME)
assert any(p.iccid == TEST_ICCID and p.nickname == TEST_NICKNAME for p in lpa.list_profiles())
lpa.enable_profile(TEST_ICCID)
new_active = lpa.get_active_profile()
assert new_active is not None
assert new_active.iccid == TEST_ICCID
assert new_active.nickname == TEST_NICKNAME
lpa.disable_profile(TEST_ICCID)
new_active = lpa.get_active_profile()
assert new_active is None
if current_active:
lpa.enable_profile(current_active.iccid)
class TestEsimDeleteHandling:
def test_delete_ignores_notification_cleanup_if_profile_is_gone(self, monkeypatch):
target_iccid = "89012804332267989477"
lpa = lpa_module.TiciLPA()
monkeypatch.setattr(lpa, "_validate_profile_exists", lambda iccid: None)
monkeypatch.setattr(lpa, "get_active_profile", lambda: Profile("8901240527117095243", "US Mobile", True, "Wireless"))
monkeypatch.setattr(lpa, "_restart_modem", lambda: None)
monkeypatch.setattr(
lpa,
"list_profiles",
lambda: [Profile("8901240527117095243", "US Mobile", True, "Wireless")],
)
monkeypatch.setattr(lpa, "_ensure_client", lambda: object())
monkeypatch.setattr(lpa_module, "delete_profile", lambda client, iccid: None)
def fail_notifications(client):
raise RuntimeError('AT command failed (AT+CGLA=2,16,"80E2910003BF2800"): AT command failed')
monkeypatch.setattr(lpa_module, "process_notifications", fail_notifications)
lpa.delete_profile(target_iccid)
def test_delete_raises_clear_error_if_profile_still_present_after_cleanup_failure(self, monkeypatch):
target_iccid = "89012804332267989477"
lpa = lpa_module.TiciLPA()
monkeypatch.setattr(lpa, "_validate_profile_exists", lambda iccid: None)
monkeypatch.setattr(lpa, "get_active_profile", lambda: Profile("8901240527117095243", "US Mobile", True, "Wireless"))
monkeypatch.setattr(lpa, "_restart_modem", lambda: None)
monkeypatch.setattr(
lpa,
"list_profiles",
lambda: [
Profile("8901240527117095243", "US Mobile", True, "Wireless"),
Profile(target_iccid, "RedPocket", False, "RedPocket"),
],
)
monkeypatch.setattr(lpa, "_ensure_client", lambda: object())
monkeypatch.setattr(lpa_module, "delete_profile", lambda client, iccid: None)
def fail_notifications(client):
raise RuntimeError('AT command failed (AT+CGLA=2,16,"80E2910003BF2800"): AT command failed')
monkeypatch.setattr(lpa_module, "process_notifications", fail_notifications)
with pytest.raises(LPAError, match="Profile delete did not finish cleanly"):
lpa.delete_profile(target_iccid)
def test_manager_maps_notification_cleanup_error(self):
error = LPAError('AT command failed (AT+CGLA=2,16,"80E2910003BF2800"): AT command failed')
assert EsimManager._map_error(error) == "Modem notification cleanup failed; refresh profiles"
class TestEsimNotificationCleanupRecovery:
def test_switch_ignores_notification_cleanup_if_target_is_enabled(self, monkeypatch):
target_iccid = "8901240527117194095"
lpa = lpa_module.TiciLPA()
monkeypatch.setattr(lpa, "_validate_profile_exists", lambda iccid: None)
monkeypatch.setattr(lpa, "_ensure_switchable_profile", lambda iccid: None)
monkeypatch.setattr(lpa, "get_active_profile", lambda: Profile("8901240527117113293", "US Mobile", True, "Wireless"))
monkeypatch.setattr(lpa, "_wait_for_modem", lambda: None)
monkeypatch.setattr(lpa, "_ensure_client", lambda: type("Client", (), {"channel": "2", "_use_csim": False})())
monkeypatch.setattr(
lpa,
"list_profiles",
lambda: [
Profile("8901240527117113293", "US Mobile", False, "Wireless"),
Profile(target_iccid, "T-Mobile", True, "Wireless"),
],
)
monkeypatch.setattr(lpa_module, "enable_profile", lambda client, iccid, refresh=True: None)
monkeypatch.setattr(
lpa_module,
"process_notifications",
lambda client: (_ for _ in ()).throw(RuntimeError('AT command failed (AT+CGLA=2,16,"80E2910003BF2800"): AT command failed')),
)
lpa.switch_profile(target_iccid)
@pytest.mark.parametrize(("is_eg25", "expected_refresh", "expected_waits", "expected_reboots"), [
(True, True, 1, 0),
(False, False, 0, 1),
])
def test_switch_profile_uses_modem_specific_refresh_behavior(self, monkeypatch, is_eg25, expected_refresh, expected_waits, expected_reboots):
target_iccid = "8901240527117194095"
lpa = object.__new__(lpa_module.TiciLPA)
lpa._is_eg25 = is_eg25
lpa.verbose = False
waits = []
reboots = []
refresh_values = []
monkeypatch.setattr(lpa, "_validate_profile_exists", lambda iccid: None)
monkeypatch.setattr(lpa, "_ensure_switchable_profile", lambda iccid: None)
monkeypatch.setattr(lpa, "get_active_profile", lambda: Profile("8901240527117113293", "US Mobile", True, "Wireless"))
monkeypatch.setattr(lpa, "_wait_for_modem", lambda: waits.append(True))
monkeypatch.setattr(lpa, "_restart_modem", lambda: reboots.append(True))
monkeypatch.setattr(lpa, "_with_lpa_error", lambda fn: fn())
monkeypatch.setattr(lpa, "_ensure_client", lambda: type("Client", (), {"channel": "2", "_use_csim": False})())
monkeypatch.setattr(
lpa,
"_process_notifications_after_state_change",
lambda validator, _recovery_message, _failure_message: validator(),
)
monkeypatch.setattr(
lpa,
"list_profiles",
lambda: [
Profile("8901240527117113293", "US Mobile", False, "Wireless"),
Profile(target_iccid, "T-Mobile", True, "Wireless"),
],
)
def fake_enable_profile(client, iccid, refresh=True):
refresh_values.append(refresh)
monkeypatch.setattr(lpa_module, "enable_profile", fake_enable_profile)
lpa.switch_profile(target_iccid)
assert refresh_values == [expected_refresh]
assert len(waits) == expected_waits
assert len(reboots) == expected_reboots
def test_download_ignores_notification_cleanup_if_profile_exists(self, monkeypatch, mocker):
target_iccid = "8901240527117194095"
lpa = lpa_module.TiciLPA()
profiles = [
[Profile("8901240527117113293", "US Mobile", True, "Wireless")],
[
Profile("8901240527117113293", "US Mobile", True, "Wireless"),
Profile(target_iccid, "T-Mobile", False, "Wireless"),
],
[
Profile("8901240527117113293", "US Mobile", True, "Wireless"),
Profile(target_iccid, "T-Mobile", False, "Wireless"),
],
]
monkeypatch.setattr(lpa, "_ensure_client", lambda: object())
monkeypatch.setattr(lpa, "_wait_for_modem", lambda: None)
profile_states = iter(profiles)
current_profiles = profiles[-1]
def list_profiles():
nonlocal current_profiles
current_profiles = next(profile_states, current_profiles)
return current_profiles
monkeypatch.setattr(lpa, "list_profiles", list_profiles)
monkeypatch.setattr(lpa_module, "download_profile", lambda client, qr: target_iccid)
set_nickname = mocker.MagicMock()
monkeypatch.setattr(lpa_module, "set_profile_nickname", set_nickname)
monkeypatch.setattr(
lpa_module,
"process_notifications",
lambda client: (_ for _ in ()).throw(RuntimeError('AT command failed (AT+CGLA=2,16,"80E2910003BF2800"): AT command failed')),
)
lpa.download_profile(TEST_ACTIVATION_CODE, "T-Mobile")
set_nickname.assert_called_once_with(mocker.ANY, target_iccid, "T-Mobile")
class TestEsimManagerSupportGating:
def test_refresh_profiles_does_not_touch_lpa_without_euicc(self, monkeypatch, mocker):
manager = EsimManager()
monkeypatch.setattr(manager, "_query_euicc_support", lambda: False)
manager._params = mocker.MagicMock()
manager._params.get.return_value = None
manager._params.get_bool.return_value = True
monkeypatch.setattr(HARDWARE, "get_device_type", lambda: "tici")
monkeypatch.setattr(HARDWARE, "get_sim_lpa", lambda: (_ for _ in ()).throw(AssertionError("LPA should not be touched")))
manager.refresh_profiles()
assert manager.get_state().profiles == []
assert manager.get_state().message == "Insert the original comma SIM card that came with the device to use eSIM"

View File

@@ -0,0 +1,102 @@
from iqpilot.system.hardware.tici.hardware import (
MM_MODEM_ACCESS_TECHNOLOGY_LTE,
MM_MODEM_STATE,
NMActiveConnectionState,
Tici,
)
from iqpilot.cereal import log
def _make_connection(mocker, connection_type: str, state: int):
connection = mocker.MagicMock()
def get_side_effect(_iface, prop, **_kwargs):
values = {
"Type": connection_type,
"State": state,
}
return values[prop]
connection.Get.side_effect = get_side_effect
return connection
def test_reboot_modem_falls_back_to_direct_at(monkeypatch, mocker):
device = Tici()
direct_runner = mocker.MagicMock()
monkeypatch.setattr(device, "get_modem", mocker.MagicMock(side_effect=ModuleNotFoundError("dbus")))
monkeypatch.setattr(device, "_run_direct_modem_command", direct_runner)
device.reboot_modem()
assert direct_runner.call_args_list == [
(("AT+CFUN=0",), {}),
(("AT+CFUN=1",), {}),
]
def test_get_network_type_ignores_non_activated_cellular(mocker):
device = Tici()
primary = _make_connection(mocker, "gsm", NMActiveConnectionState.ACTIVATING)
bus = mocker.MagicMock()
bus.get_object.return_value = primary
nm = mocker.MagicMock()
nm.Get.return_value = "/primary"
device.__dict__["bus"] = bus
device.__dict__["nm"] = nm
assert device.get_network_type() == log.DeviceState.NetworkType.none
def test_get_network_type_requires_registered_modem(monkeypatch, mocker):
device = Tici()
primary = _make_connection(mocker, "gsm", NMActiveConnectionState.ACTIVATED)
cellular = _make_connection(mocker, "gsm", NMActiveConnectionState.ACTIVATED)
bus = mocker.MagicMock()
bus.get_object.side_effect = [primary, cellular]
nm = mocker.MagicMock()
nm.Get.side_effect = ["/primary", ["/cellular"]]
modem = mocker.MagicMock()
def modem_get_side_effect(_iface, prop, **_kwargs):
values = {
"State": MM_MODEM_STATE.SEARCHING,
"AccessTechnologies": MM_MODEM_ACCESS_TECHNOLOGY_LTE,
}
return values[prop]
modem.Get.side_effect = modem_get_side_effect
device.__dict__["bus"] = bus
device.__dict__["nm"] = nm
monkeypatch.setattr(device, "get_modem", mocker.MagicMock(return_value=modem))
assert device.get_network_type() == log.DeviceState.NetworkType.none
def test_get_network_type_reports_lte_for_registered_modem(monkeypatch, mocker):
device = Tici()
primary = _make_connection(mocker, "gsm", NMActiveConnectionState.ACTIVATED)
cellular = _make_connection(mocker, "gsm", NMActiveConnectionState.ACTIVATED)
bus = mocker.MagicMock()
bus.get_object.side_effect = [primary, cellular]
nm = mocker.MagicMock()
nm.Get.side_effect = ["/primary", ["/cellular"]]
modem = mocker.MagicMock()
def modem_get_side_effect(_iface, prop, **_kwargs):
values = {
"State": MM_MODEM_STATE.CONNECTED,
"AccessTechnologies": MM_MODEM_ACCESS_TECHNOLOGY_LTE,
}
return values[prop]
modem.Get.side_effect = modem_get_side_effect
device.__dict__["bus"] = bus
device.__dict__["nm"] = nm
monkeypatch.setattr(device, "get_modem", mocker.MagicMock(return_value=modem))
assert device.get_network_type() == log.DeviceState.NetworkType.cell4G

View File

@@ -0,0 +1,53 @@
import pytest
import numpy as np
from iqpilot.system.hardware.tici import qr_decode as qr_decode_module
from iqpilot.system.hardware.tici.lpa import parse_lpa_activation_code
from iqpilot.system.hardware.tici.qr_decode import validate_lpa_activation_code
def test_parse_valid_activation_code():
version, smdp, matching = parse_lpa_activation_code("LPA:1$rsp.truphone.com$QRF-BETTERROAMING")
assert version == "1"
assert smdp == "rsp.truphone.com"
assert matching == "QRF-BETTERROAMING"
@pytest.mark.parametrize("code", [
"",
"foo",
"LPA:2$rsp.truphone.com$abc",
"LPA:1$$abc",
"LPA:1$rsp.truphone.com$",
"LPA:1$rsp.truphone.com",
])
def test_parse_invalid_activation_code(code):
with pytest.raises(ValueError):
parse_lpa_activation_code(code)
def test_qr_validator_valid():
valid, reason = validate_lpa_activation_code("LPA:1$rsp.truphone.com$QRF-123")
assert valid
assert reason == ""
def test_qr_validator_invalid():
valid, reason = validate_lpa_activation_code("https://example.com")
assert not valid
assert reason
def test_decode_qr_prefers_pyzbar(monkeypatch):
class FakeResult:
data = b"LPA:1$rsp.truphone.com$QRF-123"
monkeypatch.setattr(qr_decode_module, "_pyzbar_decode", lambda arr: [FakeResult()])
def fail_load_decoder():
raise AssertionError("quirc fallback should not be used when pyzbar succeeds")
monkeypatch.setattr(qr_decode_module, "_load_decoder", fail_load_decoder)
payloads = qr_decode_module.decode_qr(np.zeros((4, 4), dtype=np.uint8))
assert payloads == ["LPA:1$rsp.truphone.com$QRF-123"]

View File

@@ -0,0 +1,128 @@
from collections import defaultdict, deque
import pytest
import time
import numpy as np
from dataclasses import dataclass
from tabulate import tabulate
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal.services import SERVICE_LIST
from iqdbc.car.car_helpers import get_demo_car_params
from iqpilot.common.mock import mock_messages
from iqpilot.common.params import Params
from iqpilot.system.hardware.tici.power_monitor import get_power
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.system.manager.manager import manager_cleanup
SAMPLE_TIME = 8 # seconds to sample power
MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples
@dataclass
class Proc:
procs: list[str]
power: float
msgs: list[str]
rtol: float = 0.05
atol: float = 0.12
@property
def name(self):
return '+'.join(self.procs)
PROCS = [
Proc(['camerad'], 1.65, atol=0.4, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']),
Proc(['modeld'], 1.24, atol=0.2, msgs=['modelV2']),
Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']),
Proc(['encoderd'], 0.23, msgs=[]),
]
@pytest.mark.tici
class TestPowerDraw:
def setup_method(self):
Params().put("CarParams", get_demo_car_params().to_bytes())
# wait a bit for power save to disable
time.sleep(5)
def teardown_method(self):
manager_cleanup()
def get_expected_messages(self, proc):
return int(sum(SAMPLE_TIME * SERVICE_LIST[msg].frequency for msg in proc.msgs))
def valid_msg_count(self, proc, msg_counts):
msgs_received = sum(msg_counts[msg] for msg in proc.msgs)
msgs_expected = self.get_expected_messages(proc)
return np.isclose(msgs_expected, msgs_received, rtol=.02, atol=2)
def valid_power_draw(self, proc, used):
return np.isclose(used, proc.power, rtol=proc.rtol, atol=proc.atol)
def tabulate_msg_counts(self, msgs_and_power):
msg_counts = defaultdict(int)
for _, counts in msgs_and_power:
for msg, count in counts.items():
msg_counts[msg] += count
return msg_counts
def get_power_with_warmup_for_target(self, proc, prev):
socks = {msg: messaging.sub_sock(msg) for msg in proc.msgs}
for sock in socks.values():
messaging.drain_sock_raw(sock)
msgs_and_power = deque([], maxlen=SAMPLE_TIME)
start_time = time.monotonic()
while (time.monotonic() - start_time) < MAX_WARMUP_TIME:
power = get_power(1)
iteration_msg_counts = {}
for msg,sock in socks.items():
iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock))
msgs_and_power.append((power, iteration_msg_counts))
if len(msgs_and_power) < SAMPLE_TIME:
continue
msg_counts = self.tabulate_msg_counts(msgs_and_power)
now = np.mean([m[0] for m in msgs_and_power])
if self.valid_msg_count(proc, msg_counts) and self.valid_power_draw(proc, now - prev):
break
return now, msg_counts, time.monotonic() - start_time - SAMPLE_TIME
@mock_messages(['deviceMotion'])
def test_camera_procs(self, subtests):
baseline = get_power()
prev = baseline
used = {}
warmup_time = {}
msg_counts = {}
for proc in PROCS:
for p in proc.procs:
managed_processes[p].start()
now, local_msg_counts, warmup_time[proc.name] = self.get_power_with_warmup_for_target(proc, prev)
msg_counts.update(local_msg_counts)
used[proc.name] = now - prev
prev = now
manager_cleanup()
tab = [['process', 'expected (W)', 'measured (W)', '# msgs expected', '# msgs received', "warmup time (s)"]]
for proc in PROCS:
cur = used[proc.name]
expected = proc.power
msgs_received = sum(msg_counts[msg] for msg in proc.msgs)
tab.append([proc.name, round(expected, 2), round(cur, 2), self.get_expected_messages(proc), msgs_received, round(warmup_time[proc.name], 2)])
with subtests.test(proc=proc.name):
assert self.valid_msg_count(proc, msg_counts), f"expected {self.get_expected_messages(proc)} msgs, got {msgs_received} msgs"
assert self.valid_power_draw(proc, cur), f"expected {expected:.2f}W, got {cur:.2f}W"
print(tabulate(tab))
print(f"Baseline {baseline:.2f}W\n")

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
AGNOS_PY=$1
MANIFEST=$2
if [[ ! -f "$AGNOS_PY" || ! -f "$MANIFEST" ]]; then
echo "invalid args"
exit 1
fi
if systemctl is-active --quiet weston-ready; then
$DIR/updater_weston $AGNOS_PY $MANIFEST
else
$DIR/updater_magic $AGNOS_PY $MANIFEST
fi

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,71 @@
import os
import subprocess
from iqpilot.common.params import Params
SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "set_usb_storage.sh")
def apply_usb_storage_state(state: bool):
Params().put_bool("UsbStorageEnabled", state)
try:
args = ["sudo", SCRIPT_PATH]
if state:
args.append("--rebuild")
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

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# RT control procs (controlsd/card) mlockall their pages, so they are never swapped.
set -e
[ -e /sys/class/zram-control ] || exit 0
grep -q "zram0" /proc/swaps 2>/dev/null && exit 0
DISKSIZE="${ZRAM_DISKSIZE:-2G}"
echo lzo > /sys/block/zram0/comp_algorithm 2>/dev/null || true
echo "$DISKSIZE" > /sys/block/zram0/disksize
mkswap /dev/zram0 >/dev/null 2>&1
swapon -p 100 /dev/zram0
sysctl -q vm.swappiness=100 2>/dev/null || true
sysctl -q vm.page-cluster=0 2>/dev/null || true
echo "zram: $(free -m | awk '/Swap/{print $2}')MB compressed swap active"

View File

@@ -0,0 +1,205 @@
"""
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.
"""
import subprocess
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 host_role_controller(soc: Path = SOC_PLATFORM_PATH, udc_root: Path = UDC_PATH) -> Path | None:
ctrl = link_controller(udc_root)
return (soc / ctrl / "mode") if ctrl else None
def ensure_host_role(mode_path: Path | None = None) -> bool:
"""A usbpd blip mid-drive can leave the Type-C controller in 'none'/'peripheral', and the
dock can never re-enumerate until something puts the port back into host mode."""
path = mode_path if mode_path is not None else host_role_controller()
if path is None:
return False
current = read(path)
if current == "host":
return True
if current is None:
return False
rc = subprocess.run(["sudo", "-n", "sh", "-c", f"echo host > {path}"], check=False, capture_output=True)
return rc.returncode == 0 and read(path) == "host"
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