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

View File

View File

@@ -0,0 +1,200 @@
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
from __future__ import annotations
import json
import os
import socket
import subprocess
import time
from iqpilot.cereal import messaging
from iqpilot.common.params import Params
from iqpilot.common.realtime import Ratekeeper
from iqpilot.common.swaglog import cloudlog
ANDROID_ROOT = "/data/android"
HEADLESS = f"{ANDROID_ROOT}/waydroid_headless.sh"
TRIM = f"{ANDROID_ROOT}/android_trim.sh"
GUARD = f"{ANDROID_ROOT}/android_guard.sh"
LXC_ATTACH = f"{ANDROID_ROOT}/root/usr/bin/lxc-attach"
LD = f"{ANDROID_ROOT}/root/usr/lib/aarch64-linux-gnu"
# Waze's launcher is FreeMapAppActivity; there is no com.waze.MainActivity, and asking for
# one fails with "Activity class does not exist" rather than anything that names the problem.
NAV_APPS = {
"waze": ("com.waze", "com.waze/.FreeMapAppActivity"),
"maps": ("com.google.android.apps.maps", "com.google.android.apps.maps/com.google.android.maps.MapsActivity"),
}
BRIDGE_HOST = "192.168.240.112"
BRIDGE_PORT = 8099
GPS_SOURCES = ["iqLiveLocation", "liveLocationKalman", "gpsLocationExternal", "gpsLocation"]
PROVIDERS = ("gps", "fused", "network")
GPS_INTERVAL_S = 0.5
def attach(*args: str, timeout: float = 10.0) -> subprocess.CompletedProcess:
cmd = ["sudo", "-n", "env", f"LD_LIBRARY_PATH={LD}", LXC_ATTACH,
"-P", "/var/lib/waydroid/lxc", "-n", "waydroid", "--", *args]
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
def container_booted() -> bool:
try:
return attach("/system/bin/getprop", "sys.boot_completed", timeout=8).stdout.strip() == "1"
except (subprocess.SubprocessError, OSError):
return False
def bring_up() -> None:
# AGNOS mounts / read-only, so no unit file can be installed; a transient unit is the only
# way the compositor and lxc-start survive this process exiting.
subprocess.run(["sudo", "-n", "systemd-run", "--unit=iq-android", "--service-type=oneshot",
"--remain-after-exit", HEADLESS, "up"],
capture_output=True, text=True, timeout=120, check=False)
def start_guard() -> None:
subprocess.run(["sudo", "-n", "systemd-run", "--unit=iq-android-guard", GUARD],
capture_output=True, text=True, timeout=30, check=False)
def trim(nav_app: str) -> None:
subprocess.run(["sudo", "-n", "env", f"ANDROID_NAV_APP={nav_app}", TRIM],
capture_output=True, timeout=240, check=False)
def enable_mock_location() -> None:
# Wiped by every container restart, and the package form does not cover uid 0: without the
# --uid form every injection fails with SecurityException and the apps just say "no GPS".
attach("/system/bin/cmd", "appops", "set", "--uid", "0", "android:mock_location", "allow")
for p in PROVIDERS:
attach("/system/bin/cmd", "location", "providers", "add-test-provider", p)
attach("/system/bin/cmd", "location", "providers", "set-test-provider-enabled", p, "true")
# The a11y tree is only updated while the display is awake; asleep it emits nothing at all.
attach("/system/bin/svc", "power", "stayon", "true")
def inject(lat: float, lon: float) -> None:
for p in PROVIDERS:
attach("/system/bin/cmd", "location", "providers", "set-test-provider-location", p,
"--location", f"{lat:.6f},{lon:.6f}", "--accuracy", "4", timeout=6)
def read_position(sm: messaging.SubMaster) -> tuple[float, float] | None:
for src in GPS_SOURCES:
if src not in sm.data or not sm.valid.get(src, False):
continue
msg = sm[src]
for lat_a, lon_a in (("latitude", "longitude"), ("lat", "lon")):
lat = getattr(msg, lat_a, None)
lon = getattr(msg, lon_a, None)
if lat is not None and lon is not None and (lat or lon):
return float(lat), float(lon)
return None
class AndroidNavDaemon:
def __init__(self) -> None:
self.params = Params()
self.sources = [s for s in GPS_SOURCES if s in messaging.SERVICE_LIST]
self.sm = messaging.SubMaster(self.sources) if self.sources else None
self.last_inject = 0.0
self.up = False
def status(self, text: str) -> None:
self.params.put("IQAndroidNavStatus", text)
def sign_in(self) -> None:
email = self.params.get("IQAndroidNavEmail", encoding="utf8")
password = self.params.get("IQAndroidNavPassword", encoding="utf8")
if not email or not password:
return
try:
with socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=10) as s:
s.sendall(json.dumps({"cmd": "signin", "email": email, "password": password}).encode() + b"\n")
self.status("sign-in requested")
except OSError:
cloudlog.exception("androidd: sign-in request failed")
self.status("sign-in failed")
finally:
# Never let the credential outlive the one use it was handed over for.
self.params.remove("IQAndroidNavEmail")
self.params.remove("IQAndroidNavPassword")
def nav_app(self) -> str:
value = self.params.get("IQAndroidNavApp", encoding="utf8") or "waze"
return value if value in NAV_APPS else "waze"
def enforce_single_app(self, nav_app: str) -> None:
for name, (pkg, _) in NAV_APPS.items():
if name != nav_app:
attach("/system/bin/am", "force-stop", pkg)
def ensure_app_running(self, nav_app: str) -> None:
pkg, component = NAV_APPS[nav_app]
if attach("/system/bin/pidof", pkg, timeout=8).stdout.strip():
return
attach("/system/bin/am", "start", "-n", component, timeout=20)
def ensure_up(self) -> None:
if container_booted():
if not self.up:
nav_app = self.nav_app()
enable_mock_location()
trim(nav_app)
self.enforce_single_app(nav_app)
start_guard()
self.status(f"running:{nav_app}")
self.up = True
return
self.up = False
self.status("starting")
bring_up()
def step(self) -> None:
if not self.params.get_bool("IQAndroidNav"):
if self.up:
subprocess.run(["sudo", "-n", "systemctl", "stop", "iq-android", "iq-android-guard"],
capture_output=True, timeout=60, check=False)
self.up = False
self.status("disabled")
return
self.ensure_up()
if not self.up:
return
self.sign_in()
self.ensure_app_running(self.nav_app())
if self.sm is None:
return
self.sm.update(0)
now = time.monotonic()
if now - self.last_inject < GPS_INTERVAL_S:
return
position = read_position(self.sm)
if position is not None:
self.last_inject = now
try:
inject(*position)
except subprocess.SubprocessError:
cloudlog.exception("androidd: location inject failed")
def main() -> None:
if not os.path.exists(HEADLESS):
cloudlog.warning("androidd: android stack not staged, exiting")
return
daemon = AndroidNavDaemon()
rk = Ratekeeper(2.0, print_delay_threshold=None)
while True:
try:
daemon.step()
except Exception:
cloudlog.exception("androidd: step failed")
rk.keep_time()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,37 @@
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
[Unit]
Description=Konn3kt BLE Device Settings Transport
Documentation=https://gitlvb.teallvbs.xyz/teal/iqpilot
After=bluetooth.service dbus.service
Wants=bluetooth.service dbus.service
# Never give up: /data/openpilot is a symlink created by the boot-time rename
# migration, so early starts fail "failed to locate repo root". With a burst
# limit those failures permanently kill BLE for the whole boot.
StartLimitIntervalSec=0
[Service]
Type=simple
User=comma
AmbientCapabilities=CAP_SYS_NICE
Environment="IQPILOT_SOURCE_ROOT=/data/openpilot/iqpilot"
Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"
Environment="PYTHONSAFEPATH=1"
Environment="PATH=/usr/local/venv/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WorkingDirectory=/data/openpilot
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -x /usr/libexec/iqpilot/iqpilot_bundle_runner ]; then exit 0; fi; echo "Waiting for iqpilot_bundle_runner..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -e /data/openpilot/iqpilot/system ] && [ -e /data/openpilot/iqpilot/common ]; then exit 0; fi; echo "Waiting for repo root..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'if [ -f /data/openpilot/artifacts/runtime/ensure_private_installed.sh ]; then bash /data/openpilot/artifacts/runtime/ensure_private_installed.sh || true; fi'
ExecStart=/usr/libexec/iqpilot/iqpilot_bundle_runner --bundle iqpilot_hephaestusd_private --mode python-module --entry iqpilot_private.konn3kt.hephaestus.ble_transportd --daemon-name ble_transportd
TimeoutStartSec=600
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
PrivateTmp=yes
NoNewPrivileges=false
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

View File

BIN
iqpilot/system/camerad/camerad Executable file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
# Python version of system/camerad/cameras/nv12_info.h
# Calculations from third_party/linux/include/msm_media_info.h (VENUS_BUFFER_SIZE)
def align(val: int, alignment: int) -> int:
return ((val + alignment - 1) // alignment) * alignment
def get_nv12_info(width: int, height: int) -> tuple[int, int, int, int]:
"""Returns (stride, y_height, uv_height, buffer_size) for NV12 frame dimensions."""
stride = align(width, 128)
y_height = align(height, 32)
uv_height = align(height // 2, 16)
# VENUS_BUFFER_SIZE for NV12
y_plane = stride * y_height
uv_plane = stride * uv_height + 4096
size = y_plane + uv_plane + max(16 * 1024, 8 * stride)
size = align(size, 4096)
size += align(width, 512) * 512 # kernel padding for non-aligned frames
size = align(size, 4096)
return stride, y_height, uv_height, size

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import subprocess
import time
import numpy as np
from PIL import Image
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal.visionipc import VisionStreamType
from msgq.visionipc import VisionIpcClient
from iqpilot.common.params import Params
from iqpilot.common.realtime import DT_MDL
from iqpilot.system.hardware import PC
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from iqpilot.system.manager.process_config import managed_processes
VISION_STREAMS = {
"roadCameraState": VisionStreamType.VISION_STREAM_ROAD,
"driverCameraState": VisionStreamType.VISION_STREAM_DRIVER,
"wideRoadCameraState": VisionStreamType.VISION_STREAM_WIDE_ROAD,
}
def jpeg_write(fn, dat):
img = Image.fromarray(dat)
img.save(fn, "JPEG")
def yuv_to_rgb(y, u, v):
ul = np.repeat(np.repeat(u, 2).reshape(u.shape[0], y.shape[1]), 2, axis=0).reshape(y.shape)
vl = np.repeat(np.repeat(v, 2).reshape(v.shape[0], y.shape[1]), 2, axis=0).reshape(y.shape)
yuv = np.dstack((y, ul, vl)).astype(np.int16)
yuv[:, :, 1:] -= 128
m = np.array([
[1.00000, 1.00000, 1.00000],
[0.00000, -0.39465, 2.03211],
[1.13983, -0.58060, 0.00000],
])
rgb = np.dot(yuv, m).clip(0, 255)
return rgb.astype(np.uint8)
def extract_image(buf):
# NV12 format: Y plane followed by interleaved UV plane
# UV plane size is stride * uv_height, where uv_height = align(height/2, 16)
uv_height = ((buf.height // 2) + 15) // 16 * 16
uv_plane_size = buf.stride * uv_height
y = np.array(buf.data[:buf.uv_offset], dtype=np.uint8).reshape((-1, buf.stride))[:buf.height, :buf.width]
uv_data = buf.data[buf.uv_offset:buf.uv_offset + uv_plane_size]
u = np.array(uv_data[::2], dtype=np.uint8).reshape((-1, buf.stride//2))[:buf.height//2, :buf.width//2]
v = np.array(uv_data[1::2], dtype=np.uint8).reshape((-1, buf.stride//2))[:buf.height//2, :buf.width//2]
return yuv_to_rgb(y, u, v)
def get_snapshots(frame="roadCameraState", front_frame="driverCameraState"):
sockets = [s for s in (frame, front_frame) if s is not None]
sm = messaging.SubMaster(sockets)
vipc_clients = {s: VisionIpcClient("camerad", VISION_STREAMS[s], True) for s in sockets}
# wait 4 sec from camerad startup for focus and exposure
while sm[sockets[0]].frameId < int(4. / DT_MDL):
sm.update()
for client in vipc_clients.values():
client.connect(True)
# grab images
rear, front = None, None
if frame is not None:
c = vipc_clients[frame]
rear = extract_image(c.recv())
if front_frame is not None:
c = vipc_clients[front_frame]
front = extract_image(c.recv())
return rear, front
def snapshot():
params = Params()
if (not params.get_bool("IsOffroad")) or params.get_bool("IsTakingSnapshot"):
print("Already taking snapshot")
return None, None
front_camera_allowed = params.get_bool("RecordFront")
params.put_bool("IsTakingSnapshot", True)
set_offroad_alert("Offroad_IsTakingSnapshot", True)
time.sleep(2.0) # Give hardwared time to read the param, or if just started give camerad time to start
# Check if camerad is already started
try:
subprocess.check_call(["pgrep", "camerad"])
print("Camerad already running")
params.put_bool("IsTakingSnapshot", False)
params.remove("Offroad_IsTakingSnapshot")
return None, None
except subprocess.CalledProcessError:
pass
try:
# Allow testing on replay on PC
if not PC:
managed_processes['camerad'].start()
frame = "wideRoadCameraState"
front_frame = "driverCameraState" if front_camera_allowed else None
rear, front = get_snapshots(frame, front_frame)
finally:
managed_processes['camerad'].stop()
params.put_bool("IsTakingSnapshot", False)
set_offroad_alert("Offroad_IsTakingSnapshot", False)
if not front_camera_allowed:
front = None
return rear, front
if __name__ == "__main__":
pic, fpic = snapshot()
if pic is not None:
print(pic.shape)
jpeg_write("/tmp/back.jpg", pic)
if fpic is not None:
jpeg_write("/tmp/front.jpg", fpic)
else:
print("Error taking snapshot")

View File

@@ -0,0 +1,2 @@
jpegs/
test_ae_gray

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
#echo 4294967295 | sudo tee /sys/module/cam_debug_util/parameters/debug_mdl
# no CCI and UTIL, very spammy
echo 0xfffdbfff | sudo tee /sys/module/cam_debug_util/parameters/debug_mdl
#echo 0 | sudo tee /sys/module/cam_debug_util/parameters/debug_mdl
sudo dmesg -C
scons -u -j8 --minimal .
export DEBUG_FRAMES=1
export DISABLE_ROAD=1 DISABLE_WIDE_ROAD=1
#export DISABLE_DRIVER=1
export LOGPRINT=debug
./camerad

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
cd /sys/kernel/debug/tracing
echo "" > trace
echo 1 > tracing_on
#echo Y > /sys/kernel/debug/camera_icp/a5_debug_q
echo 0x1 > /sys/kernel/debug/camera_icp/a5_debug_type
echo 1 > /sys/kernel/debug/tracing/events/camera/enable
echo 0xffffffff > /sys/kernel/debug/camera_icp/a5_debug_lvl
echo 1 > /sys/kernel/debug/tracing/events/camera/cam_icp_fw_dbg/enable
cat /sys/kernel/debug/tracing/trace_pipe

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
DISABLE_ROAD=1 DISABLE_WIDE_ROAD=1 DEBUG_FRAMES=1 LOGPRINT=debug LD_PRELOAD=/data/tici_test_scripts/isp/interceptor/tmpioctl.so ./camerad

View File

@@ -0,0 +1,9 @@
#!/bin/sh
cd ..
while :; do
./camerad &
pid="$!"
sleep 2
kill -2 $pid
wait $pid
done

View File

@@ -0,0 +1,217 @@
import os
import time
import pytest
import numpy as np
from msgq.visionipc import VisionIpcClient
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal.services import SERVICE_LIST
from iqpilot.selfdrive.test.helpers import processes_context
from iqpilot.system.camerad.snapshot import VISION_STREAMS
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.tools.lib.logreader import msgs_to_time_series
TEST_TIMESPAN = 10
CAMERAS = ('roadCameraState', 'driverCameraState', 'wideRoadCameraState')
TEST_PATTERN_FRAMES = 200
TEST_PATTERN_MIN_CONFIDENCE = 10
TEST_PATTERN_CONNECT_TIMEOUT = 15
TEST_PATTERN_CONFIGS = {
'ox03c10': (41, 4),
'os04c10': (97, 4),
}
def _pattern_sample(client):
buf = client.recv(1000)
if buf is None:
return None
y = np.asarray(buf.data[:buf.uv_offset], dtype=np.uint8).reshape((-1, buf.stride))[:buf.height, :buf.width]
profile = y[:, ::8].mean(axis=1)
padded = np.pad(profile, (4, 4), mode='edge')
neighbors = [padded[i:i + len(profile)] for i in range(9) if i != 4]
residual = profile - np.median(neighbors, axis=0)
position = int(np.argmax(residual))
return client.frame_id, client.timestamp_sof, position, residual[position], buf.height
def _test_pattern_session():
samples = {camera: [] for camera in CAMERAS}
sockets = {camera: messaging.sub_sock(camera, conflate=False, timeout=100) for camera in CAMERAS}
logs = []
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setenv('SPECTRA_TEST_PATTERN', '1')
monkeypatch.setenv('SPECTRA_ERROR_PROB', '-1')
with processes_context(['camerad']) as processes:
clients = {camera: VisionIpcClient('camerad', VISION_STREAMS[camera], False) for camera in CAMERAS}
pending = set(clients)
deadline = time.monotonic() + TEST_PATTERN_CONNECT_TIMEOUT
while pending and time.monotonic() < deadline:
assert processes[0].proc is not None and processes[0].proc.exitcode is None
pending = {camera for camera in pending if not clients[camera].connect(False)}
if pending:
time.sleep(0.1)
assert not pending, f'VisionIPC connection timeout: {sorted(pending)}'
for _ in range(TEST_PATTERN_FRAMES):
for camera, client in clients.items():
sample = _pattern_sample(client)
if sample is not None:
samples[camera].append(sample)
for sock in sockets.values():
logs.extend(messaging.drain_sock(sock))
return msgs_to_time_series(logs), samples
def run_and_log(procs, services, duration):
logs = []
try:
for p in procs:
managed_processes[p].start()
socks = [messaging.sub_sock(s, conflate=False, timeout=100) for s in services]
start_time = time.monotonic()
while time.monotonic() - start_time < duration:
for s in socks:
logs.extend(messaging.drain_sock(s))
for p in procs:
assert managed_processes[p].proc.is_alive()
finally:
for p in procs:
managed_processes[p].stop()
return logs
@pytest.fixture(scope="module")
def logs():
logs = run_and_log(["camerad", ], CAMERAS, TEST_TIMESPAN)
ts = msgs_to_time_series(logs)
for cam in CAMERAS:
expected_frames = SERVICE_LIST[cam].frequency * TEST_TIMESPAN
cnt = len(ts[cam]['t'])
assert expected_frames*0.8 < cnt < expected_frames*1.2, f"unexpected frame count {cam}: {expected_frames=}, got {cnt}"
dts = np.abs(np.diff([ts[cam]['timestampSof']/1e6]) - 1000/SERVICE_LIST[cam].frequency)
assert (dts < 1.0).all(), f"{cam} dts(ms) out of spec: max diff {dts.max()}, 99 percentile {np.percentile(dts, 99)}"
return ts
@pytest.mark.tici
class TestCamerad:
def test_frame_skips(self, logs):
for c in CAMERAS:
assert set(np.diff(logs[c]['frameId'])) == {1, }, f"{c} has frame skips"
def test_frame_sync(self, logs):
n = range(len(logs['roadCameraState']['t'][:-10]))
frame_ids = {i: [logs[cam]['frameId'][i] for cam in CAMERAS] for i in n}
assert all(len(set(v)) == 1 for v in frame_ids.values()), "frame IDs not aligned"
frame_times = {i: [logs[cam]['timestampSof'][i] for cam in CAMERAS] for i in n}
diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in frame_times.items()}
laggy_frames = {k: v for k, v in diffs.items() if v > 1.1}
assert len(laggy_frames) == 0, f"Frames not synced properly: {laggy_frames=}"
def test_sanity_checks(self, logs):
self._sanity_checks(logs)
def _sanity_checks(self, ts):
for c in CAMERAS:
assert c in ts
assert len(ts[c]['t']) > 20
# not a valid request id
assert 0 not in ts[c]['requestId']
# should monotonically increase
assert np.all(np.diff(ts[c]['frameId']) >= 1)
assert np.all(np.diff(ts[c]['requestId']) >= 1)
# EOF > SOF
assert np.all((ts[c]['timestampEof'] - ts[c]['timestampSof']) > 0)
# logMonoTime > SOF
assert np.all((ts[c]['t'] - ts[c]['timestampSof']/1e9) > 1e-7)
# logMonoTime > EOF, needs some tolerance since EOF is (SOF + readout time) but there is noise in the SOF timestamping (done via IRQ)
assert np.mean((ts[c]['t'] - ts[c]['timestampEof']/1e9) > 1e-7) > 0.7 # should be mostly logMonoTime > EOF
assert np.all((ts[c]['t'] - ts[c]['timestampEof']/1e9) > -0.10) # when EOF > logMonoTime, it should never be more than two frames
def test_stress_test(self):
os.environ['SPECTRA_ERROR_PROB'] = '0.008'
logs = run_and_log(["camerad", ], CAMERAS, 10)
ts = msgs_to_time_series(logs)
# we should see some jumps from introduced errors
assert np.max([ np.max(np.diff(ts[c]['frameId'])) for c in CAMERAS ]) > 1
assert np.max([ np.max(np.diff(ts[c]['requestId'])) for c in CAMERAS ]) > 1
self._sanity_checks(ts)
@pytest.fixture(scope="module")
def test_pattern_data():
return _test_pattern_session()
@pytest.mark.tici
@pytest.mark.xdist_group("camerad_test_pattern")
class TestCameradTestPattern:
def test_frame_delivery(self, test_pattern_data):
logs, samples_by_camera = test_pattern_data
for camera in CAMERAS:
assert camera in logs
samples = samples_by_camera[camera]
assert len(samples) > TEST_PATTERN_FRAMES * 0.9
state_frame_ids = logs[camera]['frameId']
state_request_ids = logs[camera]['requestId']
vipc_frame_ids = np.array([sample[0] for sample in samples])
for source, frame_ids in (('camera state', state_frame_ids), ('VisionIPC', vipc_frame_ids)):
frame_steps = np.diff(frame_ids)
skipped = frame_ids[1:][frame_steps != 1]
assert len(skipped) == 0, f'{camera} {source} skipped frames before {skipped}'
expected_sof_step = 1e9 / SERVICE_LIST[camera].frequency
sof_step_errors = np.diff(logs[camera]['timestampSof']) - expected_sof_step
assert np.all(np.abs(sof_step_errors) < 1e6), f'{camera} SOF cadence errors: {sof_step_errors[np.abs(sof_step_errors) >= 1e6]}'
request_steps = np.diff(state_request_ids)
skipped_requests = state_request_ids[1:][request_steps != 1]
assert len(skipped_requests) == 0, f'{camera} skipped requests before {skipped_requests}'
state_sofs = dict(zip(state_frame_ids, logs[camera]['timestampSof'], strict=True))
matched_samples = [sample for sample in samples if sample[0] in state_sofs]
assert len(matched_samples) > len(samples) * 0.8
mismatched_sofs = {
frame_id: (timestamp_sof, state_sofs[frame_id]) for frame_id, timestamp_sof, *_ in matched_samples if timestamp_sof != state_sofs[frame_id]
}
assert not mismatched_sofs, f'{camera} VisionIPC/camera state SOFs disagree: {mismatched_sofs}'
def test_pattern(self, test_pattern_data):
logs, samples_by_camera = test_pattern_data
for camera in CAMERAS:
sensors = set(logs[camera]['sensor'])
assert len(sensors) == 1
sensor = sensors.pop()
assert sensor in TEST_PATTERN_CONFIGS, f'unsupported test pattern sensor: {sensor}'
cycle_frames, position_tolerance = TEST_PATTERN_CONFIGS[sensor]
samples = samples_by_camera[camera]
confident = [sample for sample in samples if sample[3] > TEST_PATTERN_MIN_CONFIDENCE]
positions = np.array([sample[2] for sample in confident])
assert len(confident) > len(samples) * 0.7, f'{camera} test pattern confidence too low'
assert len(np.unique(positions)) > 20, f'{camera} test pattern is not moving'
assert np.ptp(positions) > confident[0][4] * 0.75, f'{camera} test pattern does not span the frame'
samples_by_frame = {sample[0]: sample for sample in confident}
repeating_pairs = [(sample, samples_by_frame[sample[0] + cycle_frames]) for sample in confident if sample[0] + cycle_frames in samples_by_frame]
assert len(repeating_pairs) > 20
unexpected = [(first[0], first[2], second[2]) for first, second in repeating_pairs if abs(second[2] - first[2]) > position_tolerance]
assert len(unexpected) < len(repeating_pairs) * 0.3, f'{camera} test pattern cycle mismatches: {unexpected}'

View File

@@ -0,0 +1,51 @@
import time
import numpy as np
import pytest
from iqpilot.selfdrive.test.helpers import with_processes
from iqpilot.system.camerad.snapshot import get_snapshots
TEST_TIME = 45
REPEAT = 5
@pytest.mark.tici
class TestCamerad:
@classmethod
def setup_class(cls):
pass
def _numpy_rgb2gray(self, im):
ret = np.clip(im[:,:,2] * 0.114 + im[:,:,1] * 0.587 + im[:,:,0] * 0.299, 0, 255).astype(np.uint8)
return ret
def _is_exposure_okay(self, i, med_mean=None):
if med_mean is None:
med_mean = np.array([[0.18,0.3],[0.18,0.3]])
h, w = i.shape[:2]
i = i[h//10:9*h//10,w//10:9*w//10]
med_ex, mean_ex = med_mean
i = self._numpy_rgb2gray(i)
i_median = np.median(i) / 255.
i_mean = np.mean(i) / 255.
print([i_median, i_mean])
return med_ex[0] < i_median < med_ex[1] and mean_ex[0] < i_mean < mean_ex[1]
@with_processes(['camerad'])
def test_camera_operation(self):
passed = 0
start = time.monotonic()
while time.monotonic() - start < TEST_TIME and passed < REPEAT:
rpic, dpic = get_snapshots(frame="roadCameraState", front_frame="driverCameraState")
wpic, _ = get_snapshots(frame="wideRoadCameraState")
res = self._is_exposure_okay(rpic)
res = res and self._is_exposure_okay(dpic)
res = res and self._is_exposure_okay(wpic)
if passed > 0 and not res:
passed = -passed # fails test if any failure after first sus
break
passed += int(res)
time.sleep(2)
assert passed >= REPEAT

View File

@@ -0,0 +1,34 @@
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
[Unit]
Description=Konn3kt Flock/ALPR RF Detector
Documentation=https://gitlvb.teallvbs.xyz/teal/iqpilot
After=bluetooth.service dbus.service NetworkManager.service
Wants=bluetooth.service dbus.service
StartLimitIntervalSec=0
[Service]
Type=simple
User=comma
AmbientCapabilities=CAP_SYS_NICE
Environment="IQPILOT_SOURCE_ROOT=/data/openpilot/iqpilot"
Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"
Environment="PYTHONSAFEPATH=1"
Environment="PATH=/usr/local/venv/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WorkingDirectory=/data/openpilot
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -x /usr/libexec/iqpilot/iqpilot_bundle_runner ]; then exit 0; fi; echo "Waiting for iqpilot_bundle_runner..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -e /data/openpilot/iqpilot/system ] && [ -e /data/openpilot/iqpilot/common ]; then exit 0; fi; echo "Waiting for repo root..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'if [ -f /data/openpilot/artifacts/runtime/ensure_private_installed.sh ]; then bash /data/openpilot/artifacts/runtime/ensure_private_installed.sh || true; fi'
ExecStart=/usr/libexec/iqpilot/iqpilot_bundle_runner --bundle iqpilot_hephaestusd_private --mode python-module --entry iqpilot_private.konn3kt.flockd.flockd --daemon-name flockd
TimeoutStartSec=600
Restart=always
RestartSec=15
StandardOutput=journal
StandardError=journal
PrivateTmp=yes
NoNewPrivileges=false
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

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

View File

@@ -0,0 +1,38 @@
[Unit]
Description=Hephaestusd - Lightning Fast Konn3kt Client
Documentation=https://gitlvb.teallvbs.xyz/teal/iqpilot
After=network.target
Wants=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=comma
AmbientCapabilities=CAP_SYS_NICE
Environment="IQPILOT_SOURCE_ROOT=/data/openpilot/iqpilot"
Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"
Environment="PYTHONSAFEPATH=1"
Environment="PATH=/usr/local/venv/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WorkingDirectory=/data/openpilot
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -x /usr/libexec/iqpilot/iqpilot_bundle_runner ]; then exit 0; fi; echo "Waiting for iqpilot_bundle_runner..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -e /data/openpilot/iqpilot/system ] && [ -e /data/openpilot/iqpilot/common ]; then exit 0; fi; echo "Waiting for repo root..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'if [ -f /data/openpilot/artifacts/runtime/ensure_private_installed.sh ]; then bash /data/openpilot/artifacts/runtime/ensure_private_installed.sh || true; fi'
ExecStart=/usr/libexec/iqpilot/iqpilot_bundle_runner --bundle iqpilot_hephaestusd_private --mode python-module --entry iqpilot_private.konn3kt.hephaestus.manage_hephaestusd --daemon-name manage_hephaestusd
# Give it 10 minutes to wait for build to complete on first boot
TimeoutStartSec=600
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
PrivateTmp=yes
NoNewPrivileges=false
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,48 @@
#!/usr/bin/bash
set -e
SERVICE_FILE="/data/openpilot/iqpilot/system/ble-transportd.service"
SERVICE_NAME="ble-transportd.service"
SERVICE_OVERRIDE="/etc/systemd/system/${SERVICE_NAME}"
SERVICE_BAKED="/lib/systemd/system/${SERVICE_NAME}"
SERVICE_DROPIN="/run/systemd/system/${SERVICE_NAME}.d"
echo "Installing BLE transportd systemd service..."
if [ -f "$SERVICE_BAKED" ] && grep -q "/usr/libexec/iqpilot/iqpilot_bundle_runner" "$SERVICE_BAKED"; then
echo "Using IQ.OS baked ${SERVICE_NAME}; removing stale override if present..."
sudo mount -o remount,rw /
sudo rm -f "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
sudo mount -o remount,ro /
else
if [ ! -f "$SERVICE_FILE" ]; then
echo "ERROR: Service file not found at $SERVICE_FILE"
exit 1
fi
echo "IQ.OS baked unit unavailable; installing fallback override into /etc/systemd/system..."
sudo cp "$SERVICE_FILE" "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
fi
sudo mkdir -p "$SERVICE_DROPIN"
printf '%s\n' '[Service]' 'Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"' | sudo tee "$SERVICE_DROPIN/iqpilot-packages.conf" >/dev/null
sudo systemctl daemon-reload
echo "Enabling $SERVICE_NAME to start at boot..."
sudo systemctl enable "$SERVICE_NAME"
echo "Starting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"
echo ""
echo "Service status:"
sudo systemctl status "$SERVICE_NAME" --no-pager
echo ""
echo "Useful commands:"
echo " sudo systemctl status ble-transportd - Check service status"
echo " sudo systemctl restart ble-transportd - Restart service"
echo " sudo systemctl stop ble-transportd - Stop service"
echo " sudo journalctl -u ble-transportd -f - View live logs"

View File

@@ -0,0 +1,47 @@
#!/usr/bin/bash
set -e
SERVICE_FILE="/data/openpilot/iqpilot/system/flockd.service"
SERVICE_NAME="flockd.service"
SERVICE_OVERRIDE="/etc/systemd/system/${SERVICE_NAME}"
SERVICE_BAKED="/lib/systemd/system/${SERVICE_NAME}"
SERVICE_DROPIN="/run/systemd/system/${SERVICE_NAME}.d"
echo "Installing Flock RF detector systemd service..."
if [ -f "$SERVICE_BAKED" ] && grep -q "/usr/libexec/iqpilot/iqpilot_bundle_runner" "$SERVICE_BAKED"; then
echo "Using IQ.OS baked ${SERVICE_NAME}; removing stale override if present..."
sudo mount -o remount,rw /
sudo rm -f "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
sudo mount -o remount,ro /
else
if [ ! -f "$SERVICE_FILE" ]; then
echo "ERROR: Service file not found at $SERVICE_FILE"
exit 1
fi
echo "IQ.OS baked unit unavailable; installing fallback override into /etc/systemd/system..."
sudo cp "$SERVICE_FILE" "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
fi
sudo mkdir -p "$SERVICE_DROPIN"
printf '%s\n' '[Service]' 'Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"' | sudo tee "$SERVICE_DROPIN/iqpilot-packages.conf" >/dev/null
sudo systemctl daemon-reload
echo "Enabling $SERVICE_NAME to start at boot..."
sudo systemctl enable "$SERVICE_NAME"
echo "Starting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"
echo ""
echo "Service status:"
sudo systemctl status "$SERVICE_NAME" --no-pager
echo ""
echo "Useful commands:"
echo " sudo systemctl status flockd - Check service status"
echo " sudo systemctl restart flockd - Restart service"
echo " sudo journalctl -u flockd -f - View live logs"

View File

@@ -0,0 +1,48 @@
#!/usr/bin/bash
set -e
SERVICE_FILE="/data/openpilot/iqpilot/system/hephaestusd.service"
SERVICE_NAME="hephaestusd.service"
SERVICE_OVERRIDE="/etc/systemd/system/${SERVICE_NAME}"
SERVICE_BAKED="/lib/systemd/system/${SERVICE_NAME}"
SERVICE_DROPIN="/run/systemd/system/${SERVICE_NAME}.d"
echo "Installing Hephaestusd systemd service..."
if [ -f "$SERVICE_BAKED" ] && grep -q "/usr/libexec/iqpilot/iqpilot_bundle_runner" "$SERVICE_BAKED"; then
echo "Using IQ.OS baked ${SERVICE_NAME}; removing stale override if present..."
sudo mount -o remount,rw /
sudo rm -f "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
sudo mount -o remount,ro /
else
if [ ! -f "$SERVICE_FILE" ]; then
echo "ERROR: Service file not found at $SERVICE_FILE"
exit 1
fi
echo "IQ.OS baked unit unavailable; installing fallback override into /etc/systemd/system..."
sudo cp "$SERVICE_FILE" "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
fi
sudo mkdir -p "$SERVICE_DROPIN"
printf '%s\n' '[Service]' 'Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot/.venv/lib/python3.12/site-packages:/data/openpilot"' | sudo tee "$SERVICE_DROPIN/iqpilot-packages.conf" >/dev/null
sudo systemctl daemon-reload
echo "Enabling $SERVICE_NAME to start at boot..."
sudo systemctl enable "$SERVICE_NAME"
echo "Starting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"
echo ""
echo "Service status:"
sudo systemctl status "$SERVICE_NAME" --no-pager
echo ""
echo "Useful commands:"
echo " sudo systemctl status hephaestusd - Check service status"
echo " sudo systemctl restart hephaestusd - Restart service"
echo " sudo systemctl stop hephaestusd - Stop service"
echo " sudo journalctl -u hephaestusd -f - View live logs"

43
iqpilot/system/journald.py Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import json
import subprocess
import iqpilot.cereal.messaging as messaging
from iqpilot.common.swaglog import cloudlog
def main():
pm = messaging.PubMaster(['androidLog'])
cmd = ['journalctl', '-f', '-o', 'json']
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True)
assert proc.stdout is not None
try:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
kv = json.loads(line)
except json.JSONDecodeError:
cloudlog.exception("failed to parse journalctl output")
continue
msg = messaging.new_message('androidLog')
entry = msg.androidLog
entry.ts = int(kv.get('__REALTIME_TIMESTAMP', 0))
entry.message = json.dumps(kv)
if '_PID' in kv:
entry.pid = int(kv['_PID'])
if 'PRIORITY' in kv:
entry.priority = int(kv['PRIORITY'])
if 'SYSLOG_IDENTIFIER' in kv:
entry.tag = kv['SYSLOG_IDENTIFIER']
pm.send('androidLog', msg)
finally:
proc.terminate()
proc.wait()
if __name__ == '__main__':
main()

4
iqpilot/system/loggerd/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
loggerd
encoderd
bootlog
tests/test_logger

View File

BIN
iqpilot/system/loggerd/bootlog Executable file

Binary file not shown.

View File

@@ -0,0 +1,31 @@
import os
from iqpilot.system.hardware.hw import Paths
CAMERA_FPS = 20
SEGMENT_LENGTH = 60
PATH_DICT = {
"internal": Paths.log_root(),
"external": Paths.log_root_external()
}
def get_available_percent(default: float, path_type="internal") -> float:
try:
statvfs = os.statvfs(PATH_DICT[path_type])
available_percent = 100.0 * statvfs.f_bavail / statvfs.f_blocks
except (OSError, KeyError):
available_percent = default
return available_percent
def get_available_bytes(default: int, path_type="internal") -> int:
try:
statvfs = os.statvfs(PATH_DICT[path_type])
available_bytes = statvfs.f_bavail * statvfs.f_frsize
except (OSError, KeyError):
available_bytes = default
return available_bytes

View File

@@ -0,0 +1,45 @@
import os
from iqpilot.common.swaglog import cloudlog
from iqpilot.system.hardware.hw import Paths
PRESERVE_ATTR_NAME = b"user.preserve"
PRESERVE_ATTR_VALUE = b"1"
def recover_unclean_segments(log_root: str | None = None) -> list[str]:
# Segments with leftover .lock files are from a loggerd that never closed
# cleanly (power cut, crash). The video/log data in them is valid up to the
# last durable sync. Clear the stale locks so the deleter can manage them
# again, and preserve them: footage from an unclean shutdown is exactly the
# footage a dashcam must not throw away.
root = log_root if log_root is not None else Paths.log_root()
recovered = []
try:
dirs = os.listdir(root)
except OSError:
return recovered
for d in dirs:
seg_path = os.path.join(root, d)
if not os.path.isdir(seg_path):
continue
try:
locks = [f for f in os.listdir(seg_path) if f.endswith(".lock")]
if not locks:
continue
for lock in locks:
os.unlink(os.path.join(seg_path, lock))
setxattr = getattr(os, "setxattr", None) # not available on darwin
if setxattr is not None:
try:
setxattr(seg_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
except OSError:
pass
recovered.append(d)
except OSError:
cloudlog.exception(f"crash_recovery: failed to recover {seg_path}")
if recovered:
cloudlog.event("crash_recovery.recovered_unclean_segments", segments=sorted(recovered), error=True)
return recovered

117
iqpilot/system/loggerd/deleter.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
import os
import time
import shutil
import threading
from pathlib import Path
from iqpilot.system.hardware.hw import Paths
from iqpilot.common.swaglog import cloudlog
from iqpilot.system.loggerd.config import get_available_bytes, get_available_percent
from iqpilot.system.loggerd.uploader_common import listdir_by_creation
from iqpilot.system.loggerd.xattr_cache import getxattr
MIN_BYTES = 5 * 1024 * 1024 * 1024
MIN_PERCENT = 10
DELETE_LAST = ['boot', 'crash']
PRESERVE_ATTR_NAME = 'user.preserve'
PRESERVE_ATTR_VALUE = b'1'
PRESERVE_COUNT = 5
def has_preserve_xattr(d: str) -> bool:
return getxattr(os.path.join(Paths.log_root(), d), PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
def get_preserved_segments(dirs_by_creation: list[str]) -> set[str]:
# skip deleting most recent N preserved segments (and their prior segment)
preserved = set()
for n, d in enumerate(filter(has_preserve_xattr, reversed(dirs_by_creation))):
if n == PRESERVE_COUNT:
break
date_str, _, seg_str = d.rpartition("--")
# ignore non-segment directories
if not date_str:
continue
try:
seg_num = int(seg_str)
except ValueError:
continue
# preserve segment and two prior
for _seg_num in range(max(0, seg_num - 2), seg_num + 1):
preserved.add(f"{date_str}--{_seg_num}")
return preserved
def deleter_thread(exit_event: threading.Event):
while not exit_event.is_set():
out_of_bytes = get_available_bytes(default=MIN_BYTES + 1) < MIN_BYTES
out_of_percent = get_available_percent(default=MIN_PERCENT + 1) < MIN_PERCENT
if out_of_percent or out_of_bytes:
dirs = listdir_by_creation(Paths.log_root())
preserved_dirs = get_preserved_segments(dirs)
# remove the earliest directory we can
for delete_dir in sorted(dirs, key=lambda d: (d in DELETE_LAST, d in preserved_dirs)):
delete_path = os.path.join(Paths.log_root(), delete_dir)
if any(name.endswith(".lock") for name in os.listdir(delete_path)):
continue
if Path(Paths.log_root_external()).is_mount():
out_of_bytes_external = get_available_bytes(default=MIN_BYTES + 1, path_type="external") < MIN_BYTES
out_of_percent_external = get_available_percent(default=MIN_PERCENT + 1, path_type="external") < MIN_PERCENT
if out_of_percent_external or out_of_bytes_external:
dirs_external = listdir_by_creation(Paths.log_root_external())
# remove the earliest external directory we can
for delete_dir_external in sorted(dirs_external):
delete_path_external = os.path.join(Paths.log_root_external(), delete_dir_external)
try:
cloudlog.warning(f"deleting {delete_path_external}")
shutil.rmtree(delete_path_external)
break
except OSError:
cloudlog.exception(f"issue deleting {delete_path_external}")
# move directory from internal to external
path_external = os.path.join(Paths.log_root_external(), delete_dir)
try:
cloudlog.warning(f"moving {delete_path} to {path_external}")
start = time.monotonic()
shutil.move(delete_path, path_external)
cloudlog.warning(f"moved {delete_path} to {path_external} in {time.monotonic() - start:.2f}s")
break
except Exception:
cloudlog.error(f"issue moving {delete_path} to {path_external}")
try:
cloudlog.warning(f"deleting {delete_path}")
shutil.rmtree(delete_path)
break
except OSError:
cloudlog.exception(f"issue deleting {delete_path}")
continue
try:
cloudlog.info(f"deleting {delete_path}")
shutil.rmtree(delete_path)
break
except OSError:
cloudlog.exception(f"issue deleting {delete_path}")
exit_event.wait(.1)
else:
exit_event.wait(30)
def main():
deleter_thread(threading.Event())
if __name__ == "__main__":
main()

Binary file not shown.

BIN
iqpilot/system/loggerd/encoderd Executable file

Binary file not shown.

BIN
iqpilot/system/loggerd/loggerd Executable file

Binary file not shown.

View File

View File

@@ -0,0 +1,57 @@
import os
import random
import shutil
from pathlib import Path
import iqpilot.system.loggerd.deleter as deleter
from iqpilot.common.params import Params
from iqpilot.system.hardware.hw import Paths
from iqpilot.system.loggerd.xattr_cache import setxattr
def create_random_file(file_path: Path, size_mb: float, lock: bool = False) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
if lock:
lock_path = str(file_path) + ".lock"
os.close(os.open(lock_path, os.O_CREAT | os.O_EXCL))
chunks = 128
chunk_bytes = int(size_mb * 1024 * 1024 / chunks)
data = os.urandom(chunk_bytes)
with open(file_path, "wb") as f:
for _ in range(chunks):
f.write(data)
class DeleterTestCase:
f_type = "UNKNOWN"
root: Path
seg_num: int
seg_format: str
seg_format2: str
seg_dir: str
def setup_method(self):
shutil.rmtree(Paths.log_root(), ignore_errors=True)
Path(Paths.log_root()).mkdir(parents=True, exist_ok=True)
self.seg_num = random.randint(1, 300)
self.seg_format = "00000004--0ac3964c96--{}"
self.seg_format2 = "00000005--4c4e99b08b--{}"
self.seg_dir = self.seg_format.format(self.seg_num)
self.params = Params()
self.params.put("IsOffroad", True)
self.params.put("DongleId", "0000000000000000")
def make_file_with_data(self, f_dir: str, fn: str, size_mb: float = .1, lock: bool = False,
preserve_xattr: bytes | None = None) -> Path:
file_path = Path(Paths.log_root()) / f_dir / fn
create_random_file(file_path, size_mb, lock)
if preserve_xattr is not None:
setxattr(str(file_path.parent), deleter.PRESERVE_ATTR_NAME, preserve_xattr)
return file_path

View File

@@ -0,0 +1,117 @@
import time
import threading
from collections import namedtuple
from pathlib import Path
from collections.abc import Sequence
import iqpilot.system.loggerd.deleter as deleter
from iqpilot.common.timeout import Timeout, TimeoutException
from iqpilot.system.loggerd.tests.deleter_tests_common import DeleterTestCase
Stats = namedtuple("Stats", ['f_bavail', 'f_blocks', 'f_frsize'])
class TestDeleter(DeleterTestCase):
def fake_statvfs(self, d):
return self.fake_stats
def setup_method(self):
self.f_type = "fcamera.hevc"
super().setup_method()
self.fake_stats = Stats(f_bavail=0, f_blocks=10, f_frsize=4096)
deleter.os.statvfs = self.fake_statvfs
def start_thread(self):
self.end_event = threading.Event()
self.del_thread = threading.Thread(target=deleter.deleter_thread, args=[self.end_event])
self.del_thread.daemon = True
self.del_thread.start()
def join_thread(self):
self.end_event.set()
self.del_thread.join()
def test_delete(self):
f_path = self.make_file_with_data(self.seg_dir, self.f_type, 1)
self.start_thread()
try:
with Timeout(2, "Timeout waiting for file to be deleted"):
while f_path.exists():
time.sleep(0.01)
finally:
self.join_thread()
def assertDeleteOrder(self, f_paths: Sequence[Path], timeout: int = 5) -> None:
deleted_order = []
self.start_thread()
try:
with Timeout(timeout, "Timeout waiting for files to be deleted"):
while True:
for f in f_paths:
if not f.exists() and f not in deleted_order:
deleted_order.append(f)
if len(deleted_order) == len(f_paths):
break
time.sleep(0.01)
except TimeoutException:
print("Not deleted:", [f for f in f_paths if f not in deleted_order])
raise
finally:
self.join_thread()
assert deleted_order == f_paths, "Files not deleted in expected order"
def test_delete_order(self):
self.assertDeleteOrder([
self.make_file_with_data(self.seg_format.format(0), self.f_type),
self.make_file_with_data(self.seg_format.format(1), self.f_type),
self.make_file_with_data(self.seg_format2.format(0), self.f_type),
])
def test_delete_many_preserved(self):
self.assertDeleteOrder([
self.make_file_with_data(self.seg_format.format(0), self.f_type),
self.make_file_with_data(self.seg_format.format(1), self.f_type, preserve_xattr=deleter.PRESERVE_ATTR_VALUE),
self.make_file_with_data(self.seg_format.format(2), self.f_type),
] + [
self.make_file_with_data(self.seg_format2.format(i), self.f_type, preserve_xattr=deleter.PRESERVE_ATTR_VALUE)
for i in range(5)
])
def test_delete_last(self):
self.assertDeleteOrder([
self.make_file_with_data(self.seg_format.format(1), self.f_type),
self.make_file_with_data(self.seg_format2.format(0), self.f_type),
self.make_file_with_data(self.seg_format.format(0), self.f_type, preserve_xattr=deleter.PRESERVE_ATTR_VALUE),
self.make_file_with_data("boot", self.seg_format[:-4]),
self.make_file_with_data("crash", self.seg_format2[:-4]),
])
def test_no_delete_when_available_space(self):
f_path = self.make_file_with_data(self.seg_dir, self.f_type)
block_size = 4096
available = (10 * 1024 * 1024 * 1024) / block_size # 10GB free
self.fake_stats = Stats(f_bavail=available, f_blocks=10, f_frsize=block_size)
self.start_thread()
start_time = time.monotonic()
while f_path.exists() and time.monotonic() - start_time < 2:
time.sleep(0.01)
self.join_thread()
assert f_path.exists(), "File deleted with available space"
def test_no_delete_with_lock_file(self):
f_path = self.make_file_with_data(self.seg_dir, self.f_type, lock=True)
self.start_thread()
start_time = time.monotonic()
while f_path.exists() and time.monotonic() - start_time < 2:
time.sleep(0.01)
self.join_thread()
assert f_path.exists(), "File deleted when locked"

View File

@@ -0,0 +1,152 @@
import math
import os
import pytest
import random
import shutil
import subprocess
import time
from pathlib import Path
from parameterized import parameterized
from tqdm import trange
from iqpilot.common.params import Params
from iqpilot.common.timeout import Timeout
from iqpilot.system.hardware import TICI
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.system.hardware.hw import Paths
SEGMENT_LENGTH = 2
FULL_SIZE = 2507572
def hevc_size(w): return FULL_SIZE // 2 if w <= 1344 else FULL_SIZE
CAMERAS = [
("fcamera.hevc", 20, hevc_size, "roadEncodeIdx"),
("dcamera.hevc", 20, hevc_size, "driverEncodeIdx"),
("ecamera.hevc", 20, hevc_size, "wideRoadEncodeIdx"),
("qcamera.ts", 20, lambda x: 130000, None),
]
# we check frame count, so we don't have to be too strict on size
FILE_SIZE_TOLERANCE = 0.7
@pytest.mark.tici # TODO: all of loggerd should work on PC
class TestEncoder:
def setup_method(self):
self._clear_logs()
os.environ["LOGGERD_TEST"] = "1"
os.environ["LOGGERD_SEGMENT_LENGTH"] = str(SEGMENT_LENGTH)
def teardown_method(self):
self._clear_logs()
def _clear_logs(self):
if os.path.exists(Paths.log_root()):
shutil.rmtree(Paths.log_root())
def _get_latest_segment_path(self):
last_route = sorted(Path(Paths.log_root()).iterdir())[-1]
return os.path.join(Paths.log_root(), last_route)
# TODO: this should run faster than real time
@parameterized.expand([(True, ), (False, )])
def test_log_rotation(self, record_front):
Params().put_bool("RecordFront", record_front)
managed_processes['sensord'].start()
managed_processes['loggerd'].start()
managed_processes['encoderd'].start()
time.sleep(1.0)
managed_processes['camerad'].start()
num_segments = int(os.getenv("SEGMENTS", random.randint(2, 8)))
# wait for loggerd to make the dir for first segment
route_prefix_path = None
with Timeout(int(SEGMENT_LENGTH*3)):
while route_prefix_path is None:
try:
route_prefix_path = self._get_latest_segment_path().rsplit("--", 1)[0]
except Exception:
time.sleep(0.1)
def check_seg(i):
# check each camera file size
counts = []
first_frames = []
for camera, fps, size_lambda, encode_idx_name in CAMERAS:
if not record_front and "dcamera" in camera:
continue
file_path = f"{route_prefix_path}--{i}/{camera}"
# check file exists
assert os.path.exists(file_path), f"segment #{i}: '{file_path}' missing"
# TODO: this ffprobe call is really slow
# get width and check frame count
cmd = f"ffprobe -v error -select_streams v:0 -count_packets -show_entries stream=nb_read_packets,width -of csv=p=0 {file_path}"
if TICI:
cmd = "LD_LIBRARY_PATH=/usr/local/lib " + cmd
expected_frames = fps * SEGMENT_LENGTH
probe = subprocess.check_output(cmd, shell=True, encoding='utf8').split('\n')[0].strip().split(',')
frame_width, frame_count = int(probe[0]), int(probe[1])
counts.append(frame_count)
assert frame_count == expected_frames, \
f"segment #{i}: {camera} failed frame count check: expected {expected_frames}, got {frame_count}"
# sanity check file size
file_size = os.path.getsize(file_path)
target_size = size_lambda(frame_width)
assert math.isclose(file_size, target_size, rel_tol=FILE_SIZE_TOLERANCE), \
f"{file_path} size {file_size} isn't close to target size {target_size}"
# Check encodeIdx
if encode_idx_name is not None:
rlog_path = f"{route_prefix_path}--{i}/rlog.zst"
msgs = [m for m in LogReader(rlog_path) if m.which() == encode_idx_name]
encode_msgs = [getattr(m, encode_idx_name) for m in msgs]
valid = [m.valid for m in msgs]
segment_idxs = [m.segmentId for m in encode_msgs]
encode_idxs = [m.encodeId for m in encode_msgs]
frame_idxs = [m.frameId for m in encode_msgs]
# Check frame count
assert frame_count == len(segment_idxs)
assert frame_count == len(encode_idxs)
# Check for duplicates or skips
assert 0 == segment_idxs[0]
assert len(set(segment_idxs)) == len(segment_idxs)
assert all(valid)
assert expected_frames * i == encode_idxs[0]
first_frames.append(frame_idxs[0])
assert len(set(encode_idxs)) == len(encode_idxs)
assert 1 == len(set(first_frames))
if TICI:
expected_frames = fps * SEGMENT_LENGTH
assert min(counts) == expected_frames
shutil.rmtree(f"{route_prefix_path}--{i}")
try:
for i in trange(num_segments):
# poll for next segment
with Timeout(int(SEGMENT_LENGTH*10), error_msg=f"timed out waiting for segment {i}"):
while Path(f"{route_prefix_path}--{i+1}") not in Path(Paths.log_root()).iterdir():
time.sleep(0.1)
check_seg(i)
finally:
managed_processes['loggerd'].stop()
managed_processes['encoderd'].stop()
managed_processes['camerad'].stop()
managed_processes['sensord'].stop()

View File

@@ -0,0 +1,377 @@
import numpy as np
import os
import re
import random
import string
import subprocess
import time
from collections import defaultdict
from pathlib import Path
import pytest
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import log
from iqpilot.cereal.services import SERVICE_LIST
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.common.timeout import Timeout
from iqpilot.system.hardware.hw import Paths
from iqpilot.system.hardware import TICI
from iqpilot.system.loggerd.xattr_cache import getxattr
from iqpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.system.version import get_version
from iqpilot.tools.lib.route import RE
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.cereal.visionipc import VisionStreamType
from msgq.visionipc import VisionIpcServer
SentinelType = log.Sentinel.SentinelType
CEREAL_SERVICES = [f for f in log.Event.schema.union_fields if f in SERVICE_LIST
and SERVICE_LIST[f].should_log and "encode" not in f.lower()]
class TestLoggerd:
def _get_latest_log_dir(self):
log_dirs = sorted(Path(Paths.log_root()).iterdir(), key=lambda f: f.stat().st_mtime)
return log_dirs[-1]
def _get_log_dir(self, x):
for l in x.splitlines():
for p in l.split(' '):
path = Path(p.strip())
if path.is_dir():
return path
return None
def _get_log_fn(self, x):
for l in x.splitlines():
for p in l.split(' '):
path = Path(p.strip())
if path.is_file():
return path
return None
def _gen_bootlog(self):
with Timeout(5):
out = subprocess.check_output("./bootlog", cwd=os.path.join(BASEDIR, "iqpilot/system/loggerd"), encoding='utf-8')
log_fn = self._get_log_fn(out)
# check existence
assert log_fn is not None
return log_fn
def _check_init_data(self, msgs):
msg = msgs[0]
assert msg.which() == 'initData'
def _check_sentinel(self, msgs, route):
start_type = SentinelType.startOfRoute if route else SentinelType.startOfSegment
assert msgs[1].sentinel.type == start_type
end_type = SentinelType.endOfRoute if route else SentinelType.endOfSegment
assert msgs[-1].sentinel.type == end_type
def _publish_random_messages(self, services: list[str]) -> dict[str, list]:
pm = messaging.PubMaster(services)
managed_processes["loggerd"].start()
for s in services:
assert pm.wait_for_readers_to_update(s, timeout=5)
sent_msgs = defaultdict(list)
for i in range(random.randint(2, 10) * 100):
for s in services:
try:
m = messaging.new_message(s)
except Exception:
m = messaging.new_message(s, random.randint(2, 10))
pm.send(s, m)
sent_msgs[s].append(m)
if (i + 1) % 100 == 0:
for s in services:
assert pm.wait_for_readers_to_update(s, timeout=5)
for s in services:
assert pm.wait_for_readers_to_update(s, timeout=5)
assert managed_processes["loggerd"].stop(timeout=30) == 0
return sent_msgs
def _publish_camera_and_audio_messages(self, num_segs=1, segment_length=5):
# Use small frame sizes for testing (width, height, size, stride, uv_offset)
# NV12 format: size = stride * height * 1.5, uv_offset = stride * height
w, h = 320, 240
frame_spec = (w, h, w * h * 3 // 2, w, w * h)
streams = [
(VisionStreamType.VISION_STREAM_ROAD, frame_spec, "roadCameraState"),
(VisionStreamType.VISION_STREAM_DRIVER, frame_spec, "driverCameraState"),
(VisionStreamType.VISION_STREAM_WIDE_ROAD, frame_spec, "wideRoadCameraState"),
]
sm = messaging.SubMaster(["roadEncodeData"])
pm = messaging.PubMaster([s for _, _, s in streams] + ["rawAudioData"])
vipc_server = VisionIpcServer("camerad")
for stream_type, frame_spec, _ in streams:
vipc_server.create_buffers_with_sizes(stream_type, 40, *(frame_spec))
vipc_server.start_listener()
encoderd_ret = None
loggerd_ret = None
try:
os.environ["LOGGERD_TEST"] = "1"
os.environ["LOGGERD_SEGMENT_LENGTH"] = str(segment_length)
managed_processes["loggerd"].start()
managed_processes["encoderd"].start()
for _, _, state in streams:
assert pm.wait_for_readers_to_update(state, timeout=5)
fps = 20
for n in range(1, int(num_segs * segment_length * fps) + 1):
# send video
for stream_type, frame_spec, state in streams:
dat = np.empty(frame_spec[2], dtype=np.uint8)
vipc_server.send(stream_type, dat[:].flatten().tobytes(), n, n / fps, n / fps)
camera_state = messaging.new_message(state)
frame = getattr(camera_state, state)
frame.frameId = n
pm.send(state, camera_state)
# send audio
msg = messaging.new_message('rawAudioData')
msg.rawAudioData.data = bytes(800 * 2) # 800 samples of int16
msg.rawAudioData.sampleRate = 16000
pm.send('rawAudioData', msg)
for _, _, state in streams:
assert pm.wait_for_readers_to_update(state, timeout=5, dt=0.001)
sm.update(100)
finally:
encoderd_ret = managed_processes["encoderd"].stop(timeout=30)
loggerd_ret = managed_processes["loggerd"].stop(timeout=30)
del vipc_server
assert encoderd_ret == 0
assert loggerd_ret == 0
def test_init_data_values(self):
os.environ["CLEAN"] = random.choice(["0", "1"])
dongle = ''.join(random.choice(string.printable) for n in range(random.randint(1, 100)))
fake_params = [
# param, initData field, value
("DongleId", "dongleId", dongle),
("GitCommit", "gitCommit", "commit"),
("GitCommitDate", "gitCommitDate", "date"),
("GitBranch", "gitBranch", "branch"),
("GitRemote", "gitRemote", "remote"),
]
params = Params()
for k, _, v in fake_params:
params.put(k, v)
params.put("AccessToken", "abc")
lr = list(LogReader(str(self._gen_bootlog())))
initData = lr[0].initData
assert initData.dirty != bool(os.environ["CLEAN"])
assert initData.version == get_version()
if TICI:
assert initData._has("ufsHealth")
assert initData.ufsHealth.preEolInfo in (1, 2, 3)
assert 1 <= initData.ufsHealth.lifeTimeEstimateA <= 11
assert 1 <= initData.ufsHealth.lifeTimeEstimateB <= 11
assert len(initData.ufsHealth.vendorHealthReport) == 32
else:
assert not initData._has("ufsHealth")
if os.path.isfile("/proc/cmdline"):
with open("/proc/cmdline") as f:
assert list(initData.kernelArgs) == f.read().strip().split(" ")
with open("/proc/version") as f:
assert initData.kernelVersion == f.read()
# check params
logged_params = {entry.key: entry.value for entry in initData.params.entries}
expected_params = {k for k, _, __ in fake_params} | {'AccessToken', 'BootCount'}
assert set(logged_params.keys()) == expected_params, set(logged_params.keys()) ^ expected_params
assert logged_params['AccessToken'] == b'', f"DONT_LOG param value was logged: {repr(logged_params['AccessToken'])}"
for param_key, initData_key, v in fake_params:
assert getattr(initData, initData_key) == v
assert logged_params[param_key].decode() == v
@pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing
def test_rotation(self):
Params().put_bool("RecordFront", True)
expected_files = {"rlog.zst", "qlog.zst", "qcamera.ts", "fcamera.hevc", "dcamera.hevc", "ecamera.hevc"}
num_segs = random.randint(2, 3)
length = random.randint(4, 5) # H264 encoder uses 40 lookahead frames and does B-frame reordering, so minimum 3 seconds before qcam output
self._publish_camera_and_audio_messages(num_segs=num_segs, segment_length=length)
route_path = str(self._get_latest_log_dir()).rsplit("--", 1)[0]
for n in range(num_segs):
p = Path(f"{route_path}--{n}")
logged = {f.name for f in p.iterdir() if f.is_file()}
diff = logged ^ expected_files
assert len(diff) == 0, f"didn't get all expected files. seg={n} {route_path=}, {diff=}\n{logged=} {expected_files=}"
def test_bootlog(self):
# generate bootlog with fake launch log
launch_log = ''.join(str(random.choice(string.printable)) for _ in range(100))
with open("/tmp/launch_log", "w") as f:
f.write(launch_log)
bootlog_path = self._gen_bootlog()
lr = list(LogReader(str(bootlog_path)))
# check length
assert len(lr) == 2 # boot + initData
self._check_init_data(lr)
# check msgs
bootlog_msgs = [m for m in lr if m.which() == 'boot']
assert len(bootlog_msgs) == 1
# sanity check values
boot = bootlog_msgs.pop().boot
assert abs(boot.wallTimeNanos - time.time_ns()) < 5*1e9 # within 5s
assert boot.launchLog == launch_log
if TICI:
for fn in ["console-ramoops", "pmsg-ramoops-0"]:
path = Path(os.path.join("/sys/fs/pstore/", fn))
if path.is_file():
with open(path, "rb") as f:
expected_val = f.read()
bootlog_val = [e.value for e in boot.pstore.entries if e.key == fn][0]
assert expected_val == bootlog_val
else:
assert len(boot.pstore.entries) == 0
# next one should increment by one
bl1 = re.match(RE.LOG_ID_V2, bootlog_path.name)
bl2 = re.match(RE.LOG_ID_V2, self._gen_bootlog().name)
assert bl1.group('uid') != bl2.group('uid')
assert int(bl1.group('count')) == 0 and int(bl2.group('count')) == 1
def test_qlog(self):
qlog_services = [s for s in CEREAL_SERVICES if SERVICE_LIST[s].decimation is not None]
no_qlog_services = [s for s in CEREAL_SERVICES if SERVICE_LIST[s].decimation is None]
services = random.sample(qlog_services, random.randint(2, min(10, len(qlog_services)))) + \
random.sample(no_qlog_services, random.randint(2, min(10, len(no_qlog_services))))
sent_msgs = self._publish_random_messages(services)
qlog_path = os.path.join(self._get_latest_log_dir(), "qlog.zst")
lr = list(LogReader(qlog_path))
# check initData and sentinel
self._check_init_data(lr)
self._check_sentinel(lr, True)
recv_msgs = defaultdict(list)
for m in lr:
recv_msgs[m.which()].append(m)
for s, msgs in sent_msgs.items():
recv_cnt = len(recv_msgs[s])
if s in no_qlog_services:
# check services with no specific decimation aren't in qlog
assert recv_cnt == 0, f"got {recv_cnt} {s} msgs in qlog"
else:
# check logged message count matches decimation
expected_cnt = (len(msgs) - 1) // SERVICE_LIST[s].decimation + 1
assert recv_cnt == expected_cnt, f"expected {expected_cnt} msgs for {s}, got {recv_cnt}"
def test_rlog(self):
services = random.sample(CEREAL_SERVICES, random.randint(5, 10))
sent_msgs = self._publish_random_messages(services)
lr = list(LogReader(os.path.join(self._get_latest_log_dir(), "rlog.zst")))
# check initData and sentinel
self._check_init_data(lr)
self._check_sentinel(lr, True)
# check all messages were logged and in order
lr = lr[2:-1] # slice off initData and both sentinels
for m in lr:
sent = sent_msgs[m.which()].pop(0)
sent.clear_write_flag()
assert sent.to_bytes() == m.as_builder().to_bytes()
def test_preserving_bookmarked_segments(self):
services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) | {"userBookmark"}
self._publish_random_messages(services)
segment_dir = self._get_latest_log_dir()
assert getxattr(segment_dir, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
def test_not_preserving_nonbookmarked_segments(self):
services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) - {"userBookmark", "audioFeedback"}
self._publish_random_messages(services)
segment_dir = self._get_latest_log_dir()
assert getxattr(segment_dir, PRESERVE_ATTR_NAME) is None
@pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing
@pytest.mark.parametrize("record_front", [True, False])
def test_record_front(self, record_front):
params = Params()
params.put_bool("RecordFront", record_front)
self._publish_camera_and_audio_messages()
dcamera_hevc_exists = os.path.exists(os.path.join(self._get_latest_log_dir(), 'dcamera.hevc'))
assert dcamera_hevc_exists == record_front
@pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing
@pytest.mark.parametrize("record_audio", [True, False])
def test_record_audio(self, record_audio):
params = Params()
params.put_bool("RecordAudio", record_audio)
self._publish_camera_and_audio_messages()
qcamera_ts_path = os.path.join(self._get_latest_log_dir(), 'qcamera.ts')
ffprobe_cmd = f"ffprobe -i {qcamera_ts_path} -show_streams -select_streams a -loglevel error"
has_audio_stream = subprocess.run(ffprobe_cmd, shell=True, capture_output=True).stdout.strip() != b''
assert has_audio_stream == record_audio
raw_audio_in_rlog = any(m.which() == 'rawAudioData' for m in LogReader(os.path.join(self._get_latest_log_dir(), 'rlog.zst')))
assert raw_audio_in_rlog == record_audio
@pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing
def test_record_audio_init_failure_fails_open(self):
params = Params()
params.put_bool("RecordAudio", True)
os.environ["LOGGERD_TEST_AUDIO_INIT_FAIL"] = "1"
try:
self._publish_camera_and_audio_messages()
finally:
os.environ.pop("LOGGERD_TEST_AUDIO_INIT_FAIL", None)
latest_log_dir = self._get_latest_log_dir()
qcamera_ts_path = os.path.join(latest_log_dir, 'qcamera.ts')
assert os.path.exists(qcamera_ts_path)
ffprobe_cmd = f"ffprobe -i {qcamera_ts_path} -show_streams -select_streams a -loglevel error"
has_audio_stream = subprocess.run(ffprobe_cmd, shell=True, capture_output=True).stdout.strip() != b''
assert has_audio_stream is False
raw_audio_in_rlog = any(m.which() == 'rawAudioData' for m in LogReader(os.path.join(latest_log_dir, 'rlog.zst')))
assert raw_audio_in_rlog is True

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
cd /sys/kernel/debug/tracing
echo "" > trace
echo 1 > tracing_on
echo 1 > /sys/kernel/debug/tracing/events/msm_vidc/enable
echo 0xff > /sys/module/videobuf2_core/parameters/debug
echo 0x7fffffff > /sys/kernel/debug/msm_vidc/debug_level
echo 0xff > /sys/devices/platform/soc/aa00000.qcom,vidc/video4linux/video33/dev_debug
cat /sys/kernel/debug/tracing/trace_pipe

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import os
from iqpilot.common.swaglog import cloudlog
def get_directory_sort(d: str) -> list[str]:
prefix = ["0"] if d.startswith("2024-") else ["1"]
return prefix + [s.rjust(10, "0") for s in d.rsplit("--", 1)]
def listdir_by_creation(d: str) -> list[str]:
if not os.path.isdir(d):
return []
try:
paths = [f for f in os.listdir(d) if os.path.isdir(os.path.join(d, f))]
return sorted(paths, key=get_directory_sort)
except OSError:
cloudlog.exception("uploader_common.listdir_by_creation_failed")
return []

View File

@@ -0,0 +1,28 @@
import errno
import os
import xattr
_cached_attributes: dict[tuple[str, str], tuple[tuple[int, int, int], bytes | None]] = {}
def getxattr(path: str, attr_name: str) -> bytes | None:
key = (path, attr_name)
st = os.stat(path)
identity = (st.st_dev, st.st_ino, st.st_ctime_ns)
cached = _cached_attributes.get(key)
if cached is None or cached[0] != identity:
try:
response = xattr.getxattr(path, attr_name)
except OSError as e:
# ENODATA (Linux) or ENOATTR (macOS) means attribute hasn't been set
if e.errno == errno.ENODATA or (hasattr(errno, 'ENOATTR') and e.errno == errno.ENOATTR):
response = None
else:
raise
_cached_attributes[key] = (identity, response)
return _cached_attributes[key][1]
def setxattr(path: str, attr_name: str, attr_value: bytes) -> None:
xattr.setxattr(path, attr_name, attr_value)
st = os.stat(path)
_cached_attributes[(path, attr_name)] = ((st.st_dev, st.st_ino, st.st_ctime_ns), attr_value)

58
iqpilot/system/logmessaged.py Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
import zmq
from typing import NoReturn
import iqpilot.cereal.messaging as messaging
from iqpilot.common.logging_extra import SwagLogFileFormatter
from iqpilot.system.hardware.hw import Paths
from iqpilot.common.swaglog import get_file_handler
def close_log_handler(log_handler):
try:
log_handler.close()
except ValueError:
pass
def main() -> NoReturn:
log_handler = get_file_handler()
log_handler.setFormatter(SwagLogFileFormatter(None))
log_level = 20 # logging.INFO
ctx = zmq.Context.instance()
sock = ctx.socket(zmq.PULL)
sock.bind(Paths.swaglog_ipc())
# and we publish them
log_message_sock = messaging.pub_sock('logMessage')
error_log_message_sock = messaging.pub_sock('errorLogMessage')
try:
while True:
dat = b''.join(sock.recv_multipart())
level = dat[0]
record = dat[1:].decode("utf-8")
if level >= log_level:
log_handler.emit(record)
if len(record) > 2*1024*1024:
print("WARNING: log too big to publish", len(record))
print(record[:100])
continue
# then we publish them
msg = messaging.new_message(None, valid=True, logMessage=record)
log_message_sock.send(msg.to_bytes())
if level >= 40: # logging.ERROR
msg = messaging.new_message(None, valid=True, errorLogMessage=record)
error_log_message_sock.send(msg.to_bytes())
finally:
sock.close()
ctx.term()
close_log_handler(log_handler)
if __name__ == "__main__":
main()

View File

198
iqpilot/system/manager/build.py Executable file
View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
from pathlib import Path
# NOTE: Do NOT import anything here that needs be built (e.g. params)
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.spinner import Spinner
from iqpilot.common.text_window import TextWindow
from iqpilot.common.swaglog import cloudlog, add_file_handler
from iqpilot.system.hardware import HARDWARE, AGNOS
from iqpilot.system.version import get_build_metadata
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9
CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache")
TOTAL_SCONS_NODES = 5500
MAX_BUILD_PROGRESS = 100
def get_job_sequence() -> list[int]:
env_override = os.environ.get("SCONS_MAX_JOBS")
if env_override is not None:
try:
max_jobs = max(1, int(env_override))
except ValueError:
max_jobs = 1
else:
detected_jobs = os.cpu_count() or 2
max_jobs = min(detected_jobs, 3 if AGNOS else detected_jobs)
candidates = [max_jobs, max_jobs // 2, 1]
jobs: list[int] = []
for candidate in candidates:
candidate = max(1, int(candidate))
if candidate not in jobs:
jobs.append(candidate)
return jobs
class _SilentProgress:
"""Not a UI element: a do-nothing sink for build()'s progress calls, used when
updated.py builds in the background at update-apply time so that NO spinner or
window is shown. The normal boot path still uses the real Spinner."""
def update(self, spinner_text: str) -> None:
pass
def update_progress(self, cur: float, total: float) -> None:
pass
def close(self) -> None:
pass
REGISTRY_ARTIFACTS = [
"iqpilot/cereal/services.h",
"iqpilot/cereal/messaging/socketmaster.o",
"iqpilot/cereal/libsocketmaster.a",
"iqpilot/cereal/messaging/bridge",
"iqpilot/selfdrive/iqlocd/iqlocd",
"iqpilot/selfdrive/pandad/pandad",
"iqpilot/system/camerad/camerad",
"iqpilot/system/loggerd/loggerd",
"iqpilot/system/loggerd/encoderd",
"iqpilot/system/loggerd/bootlog",
]
def stale_registry_artifacts(basedir: str = BASEDIR) -> list[str]:
from iqpilot.cereal.services import REGISTRY_TAG_PREFIX, registry_tag
expected = registry_tag().encode()
prefix = REGISTRY_TAG_PREFIX.encode()
stale = []
for rel in REGISTRY_ARTIFACTS:
path = os.path.join(basedir, rel)
if not os.path.isfile(path):
continue
with open(path, "rb") as f:
data = f.read()
if prefix in data and expected not in data:
stale.append(rel)
return stale
def purge_stale_registry_header(basedir: str = BASEDIR) -> bool:
from iqpilot.cereal.services import registry_tag
header = os.path.join(basedir, REGISTRY_ARTIFACTS[0])
if not os.path.isfile(header):
return False
with open(header, "rb") as f:
stamped = registry_tag().encode() in f.read()
if stamped:
return False
purge_registry_artifacts([], basedir)
return True
def purge_registry_artifacts(stale: list[str], basedir: str = BASEDIR) -> None:
for rel in set(stale) | set(REGISTRY_ARTIFACTS[:3]):
path = os.path.join(basedir, rel)
if os.path.isfile(path):
os.remove(path)
def build(spinner, dirty: bool = False, minimal: bool = False, show_error_window: bool = True, registry_retry: bool = False) -> None:
env = os.environ.copy()
env.pop('PWD', None)
env['SCONS_PROGRESS'] = "1"
extra_args = ["--minimal"] if minimal else []
if AGNOS:
HARDWARE.set_power_save(False)
os.sched_setaffinity(0, range(8)) # ensure we can use the isolcpus cores
# building with all cores can result in using too
# much memory, so retry with less parallelism
if purge_stale_registry_header():
cloudlog.error("generated service registry header was stale, regenerating")
compile_output: list[bytes] = []
for n in get_job_sequence():
compile_output.clear()
command = [sys.executable, "-m", "SCons", f"-j{int(n)}", "--cache-populate", *extra_args]
scons: subprocess.Popen = subprocess.Popen(command, cwd=BASEDIR, env=env, stderr=subprocess.PIPE)
assert scons.stderr is not None
# Read progress from stderr and update spinner
while scons.poll() is None:
try:
line = scons.stderr.readline()
if line is None:
continue
line = line.rstrip()
prefix = b'progress: '
if line.startswith(prefix):
i = int(line[len(prefix):])
spinner.update_progress(MAX_BUILD_PROGRESS * min(0.99, i / TOTAL_SCONS_NODES), 100.)
elif len(line):
compile_output.append(line)
print(line.decode('utf8', 'replace'))
except Exception:
pass
if scons.returncode == 0:
spinner.update_progress(100, 100.)
break
if scons.returncode != 0:
# Read remaining output
if scons.stderr is not None:
compile_output += scons.stderr.read().split(b'\n')
# Build failed log errors
error_s = b"\n".join(compile_output).decode('utf8', 'replace')
add_file_handler(cloudlog)
cloudlog.error("scons build failed\n" + error_s)
# Show TextWindow
spinner.close()
if not os.getenv("CI") and show_error_window:
with TextWindow("IQ.Pilot failed to build\n \n" + error_s) as t:
t.wait_for_exit()
exit(1)
stale = stale_registry_artifacts()
if stale and not registry_retry:
cloudlog.error(f"compiled service registry is stale in {', '.join(stale)}, rebuilding messaging")
purge_registry_artifacts(stale)
build(spinner, dirty, minimal, show_error_window, registry_retry=True)
return
if stale:
error_s = "compiled service registry is still stale after rebuild: " + ", ".join(stale)
add_file_handler(cloudlog)
cloudlog.error(error_s)
spinner.close()
if not os.getenv("CI") and show_error_window:
with TextWindow("IQ.Pilot failed to build\n \n" + error_s) as t:
t.wait_for_exit()
exit(1)
# enforce max cache size
cache_files = [f for f in CACHE_DIR.rglob('*') if f.is_file()]
cache_files.sort(key=lambda f: f.stat().st_mtime)
cache_size = sum(f.stat().st_size for f in cache_files)
for f in cache_files:
if cache_size < MAX_CACHE_SIZE:
break
cache_size -= f.stat().st_size
f.unlink()
if __name__ == "__main__":
headless = "--headless" in sys.argv
spinner = _SilentProgress() if headless else Spinner()
spinner.update_progress(0, 100)
build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS, show_error_window = not headless)

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Define the service name
SERVICE_NAME="actions.runner.iqpilot.$(uname -n)"
# Function to control the service
control_service() {
local action=$1 # Store the function argument in a local variable
sudo systemctl $action ${SERVICE_NAME}
}
service_exists_and_is_loaded() {
sudo systemctl status ${SERVICE_NAME} &>/dev/null
if [[ $? -ne 4 ]]; then
return 0 # Service is known to systemd (i.e., loaded)
else
return 1 # Service is unknown to systemd (i.e., not loaded)
fi
}
# Check for required argument
if [[ -z $1 ]] || { [[ $1 != "start" ]] && [[ $1 != "stop" ]]; }; then
echo "Usage: $0 {start|stop}"
exit 1
fi
# Store the script argument in a descriptive variable
ACTION=$1
# Trap EXIT signal (Ctrl+C) and stop the service
trap 'control_service stop ; exit' SIGINT SIGKILL EXIT
# Enter the main loop
while true; do
# Check if the service is actually present on the system
if service_exists_and_is_loaded; then
control_service $ACTION # Call the function with the specified action
fi
sleep 1 # Pause before the next iteration
done

View File

@@ -0,0 +1,104 @@
import errno
import fcntl
import os
import sys
import pathlib
import shutil
import signal
import subprocess
import tempfile
import threading
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
def unblock_stdout() -> None:
# get a non-blocking stdout
child_pid, child_pty = os.forkpty()
if child_pid != 0: # parent
# child is in its own process group, manually pass kill signals
signal.signal(signal.SIGINT, lambda signum, frame: os.kill(child_pid, signal.SIGINT))
signal.signal(signal.SIGTERM, lambda signum, frame: os.kill(child_pid, signal.SIGTERM))
fcntl.fcntl(sys.stdout, fcntl.F_SETFL, fcntl.fcntl(sys.stdout, fcntl.F_GETFL) | os.O_NONBLOCK)
while True:
try:
dat = os.read(child_pty, 4096)
except OSError as e:
if e.errno == errno.EIO:
break
continue
if not dat:
break
try:
sys.stdout.write(dat.decode('utf8'))
except (OSError, UnicodeDecodeError):
pass
# os.wait() returns a tuple with the pid and a 16 bit value
# whose low byte is the signal number and whose high byte is the exit status
exit_status = os.wait()[1] >> 8
os._exit(exit_status)
def write_onroad_params(started, params):
params.put_bool("IsOnroad", started)
params.put_bool("IsOffroad", not started)
def heal_param_perms():
"""Self-heal for a boot-bricking failure mode: a stray root process occasionally
writes a param (seen with RouteCount/CurrentRoute) as root:root 0600, which manager
(comma) then can't read — crashing save_bootlog and any param read. Detect params we
don't own and chown them back + make them readable. Needs root to chown another user's
file, so it shells to passwordless sudo (device grants it); best-effort, never raises,
never blocks boot. No-op when everything is already ours (the common case: no sudo)."""
try:
param_path = Params().get_param_path()
uid, gid = os.getuid(), os.getgid()
stray = []
for name in os.listdir(param_path):
p = os.path.join(param_path, name)
try:
if os.stat(p).st_uid != uid:
stray.append(p)
except OSError:
pass
if stray:
subprocess.run(["sudo", "-n", "chown", f"{uid}:{gid}", *stray], check=False, timeout=15,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["sudo", "-n", "chmod", "644", *stray], check=False, timeout=15,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
def save_bootlog():
# copy current params
tmp = tempfile.mkdtemp()
params_dirname = pathlib.Path(Params().get_param_path()).name
params_dir = os.path.join(tmp, params_dirname)
# Params are rewritten atomically (unlink + rename) by other processes, so a
# value can vanish between copytree's listing and the copy; a param may also be
# unreadable (e.g. a root-owned RouteCount/CurrentRoute). Skip any file we can't
# copy instead of raising — the bootlog snapshot is best-effort and must NOT block boot.
def _copy_skip_missing(src, dst, *, follow_symlinks=True):
try:
shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
except OSError:
pass
shutil.copytree(Params().get_param_path(), params_dir, dirs_exist_ok=True, copy_function=_copy_skip_missing)
def fn(tmpdir):
env = os.environ.copy()
env['PARAMS_COPY_PATH'] = tmpdir
subprocess.call("./bootlog", cwd=os.path.join(BASEDIR, "iqpilot/system/loggerd"), env=env)
shutil.rmtree(tmpdir)
t = threading.Thread(target=fn, args=(tmp, ))
t.daemon = True
t.start()

309
iqpilot/system/manager/manager.py Executable file
View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
import datetime
import os
import signal
import sys
import time
import traceback
# sync $PWD before cereal/kj loads (and for spawned procs) or kj warns "PWD doesn't match"
os.environ['PWD'] = os.getcwd()
from iqpilot.cereal import log
import iqpilot.cereal.messaging as messaging
import iqpilot.system.sentry as sentry
from iqpilot.common.utils import atomic_write
from iqpilot.common.params import Params, ParamKeyFlag
from iqpilot.common.text_window import TextWindow
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.loggerd.crash_recovery import recover_unclean_segments
from iqpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog, heal_param_perms
from iqpilot.system.manager.process import ensure_running
from iqpilot.system.manager.process_config import managed_processes
from iqpilot.konn3kt.registration import register, UNREGISTERED_DONGLE_ID
from iqpilot.common.swaglog import cloudlog, add_file_handler
from iqpilot.system.version import get_build_metadata
from iqpilot.system.hardware.hw import Paths
MODELD_WATCHDOG_TIMEOUT = 30.0
def update_modeld_watchdog(deadline: float | None, started: bool, model_updated: bool, process, now: float) -> float | None:
running = process.proc is not None and process.proc.is_alive()
if not started or not running:
return None
if deadline is None or model_updated:
return now + MODELD_WATCHDOG_TIMEOUT
if now >= deadline:
cloudlog.error("iqmodeld is alive but not publishing modelV2; restarting")
process.restart()
return now + MODELD_WATCHDOG_TIMEOUT
return deadline
def manager_init() -> None:
heal_param_perms()
save_bootlog()
# loggerd isn't running yet, so any leftover .lock marks an unclean shutdown:
# unlock those segments and preserve them (dashcam footage from power cuts)
try:
recover_unclean_segments()
except Exception:
cloudlog.exception("recover_unclean_segments failed")
try:
from iqpilot.system.hardware import TICI
if TICI:
from iqpilot.system.hardware.tici.usb_storage import ensure_ncm_gadget, suspend_usb_input
ensure_ncm_gadget()
if Params().get_bool("IQEmacEnabled") or Params().get_bool("IQEgpuEnabled"):
suspend_usb_input(True)
except Exception:
cloudlog.exception("emac usb setup failed")
build_metadata = get_build_metadata()
params = Params()
params.clear_all(ParamKeyFlag.CLEAR_ON_MANAGER_START)
params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION)
params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION)
params.clear_all(ParamKeyFlag.CLEAR_ON_IGNITION_ON)
if build_metadata.release_channel:
params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY)
# device boot mode
if params.get("DeviceBootMode") == 1: # start in Always Offroad mode
params.put_bool("IQAlwaysOffroad", True)
if params.get_bool("RecordFrontLock"):
params.put_bool("RecordFront", True)
# set unset params to their default value
initialized_defaults = {}
for k in params.all_keys():
default_value = params.get_default_value(k)
if default_value is not None and params.get(k) is None:
params.put(k, default_value)
if default_value is not None:
initialized_defaults[k] = params.get(k)
try:
from iqpilot.selfdrive.iqmodeld.models.helpers import seed_default_bundle_if_unset
seed_default_bundle_if_unset(params)
except Exception:
cloudlog.exception("failed to seed default model bundle")
for k, value in initialized_defaults.items():
if value is not None and params.get(k) is None:
params.put(k, value)
# Create folders needed for msgq
try:
os.mkdir(Paths.shm_path())
except FileExistsError:
pass
except PermissionError:
print(f"WARNING: failed to make {Paths.shm_path()}")
# set params
serial = HARDWARE.get_serial()
params.put("Version", build_metadata.openpilot.version)
params.put("GitCommit", build_metadata.openpilot.git_commit)
params.put("GitCommitDate", build_metadata.openpilot.git_commit_date)
params.put("GitBranch", build_metadata.channel)
params.put("GitRemote", build_metadata.openpilot.git_origin)
params.put_bool("IsDevelopmentBranch", build_metadata.development_channel)
params.put_bool("IsTestedBranch", build_metadata.tested_channel)
params.put_bool("IsReleaseBranch", build_metadata.release_channel)
params.put_bool("IsReleaseIqBranch", build_metadata.release_channel)
params.put("HardwareSerial", serial)
# set dongle id
reg_res = register(show_spinner=True)
if reg_res:
dongle_id = reg_res
else:
raise Exception(f"Registration failed for device {serial}")
os.environ['DONGLE_ID'] = dongle_id # Needed for swaglog
os.environ['GIT_ORIGIN'] = build_metadata.openpilot.git_normalized_origin # Needed for swaglog
os.environ['GIT_BRANCH'] = build_metadata.channel # Needed for swaglog
os.environ['GIT_COMMIT'] = build_metadata.openpilot.git_commit # Needed for swaglog
if not build_metadata.openpilot.is_dirty:
os.environ['CLEAN'] = '1'
# init logging
sentry.init(sentry.SentryProject.SELFDRIVE)
cloudlog.bind_global(dongle_id=dongle_id,
version=build_metadata.openpilot.version,
origin=build_metadata.openpilot.git_normalized_origin,
branch=build_metadata.channel,
commit=build_metadata.openpilot.git_commit,
dirty=build_metadata.openpilot.is_dirty,
device=HARDWARE.get_device_type())
# preimport all processes
for p in managed_processes.values():
p.prepare()
def manager_cleanup() -> None:
# send signals to kill all procs
for p in managed_processes.values():
p.stop(block=False)
# ensure all are killed
for p in managed_processes.values():
p.stop(block=True)
cloudlog.info("everything is dead")
def manager_thread() -> None:
cloudlog.bind(daemon="manager")
cloudlog.info("manager start")
cloudlog.info({"environ": os.environ})
params = Params()
ignore: list[str] = []
if params.get("DongleId") in (None, UNREGISTERED_DONGLE_ID):
ignore += ["manage_hephaestusd", "iquploaderd"]
if os.getenv("NOBOARD") is not None:
ignore.append("pandad")
ignore += [x for x in os.getenv("BLOCK", "").split(",") if len(x) > 0]
sm = messaging.SubMaster(['deviceState', 'carParams', 'pandaStates', 'modelV2'], poll='deviceState')
pm = messaging.PubMaster(['managerState'])
write_onroad_params(False, params)
ensure_running(managed_processes.values(), False, params=params, CP=sm['carParams'], not_run=ignore)
started_prev = False
ignition_prev = False
running_prev = None
modeld_deadline = None
while True:
sm.update(1000)
started = sm['deviceState'].started
if started and not started_prev:
try:
HARDWARE.set_power_save(False)
except Exception:
cloudlog.exception("failed to leave power save on onroad transition")
params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION)
elif not started and started_prev:
params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION)
ignition = any(ps.ignitionLine or ps.ignitionCan for ps in sm['pandaStates'] if ps.pandaType != log.PandaState.PandaType.unknown)
if ignition and not ignition_prev:
params.clear_all(ParamKeyFlag.CLEAR_ON_IGNITION_ON)
# update onroad params, which drives pandad's safety setter thread
if started != started_prev:
write_onroad_params(started, params)
started_prev = started
ignition_prev = ignition
ensure_running(managed_processes.values(), started, params=params, CP=sm['carParams'], not_run=ignore)
modeld_deadline = update_modeld_watchdog(
modeld_deadline, started, sm.updated['modelV2'], managed_processes['iqmodeld'], time.monotonic()
)
# print only on change (reprinting every loop floods the shared tmux); always logged
procs = [p for p in managed_processes.values() if p.proc]
running = ' '.join(
("\u001b[32m{}\u001b[0m".format(p.name) if p.proc.is_alive()
else "\u001b[1;31m\u2717 {}\u001b[0m".format(p.name))
for p in procs)
cloudlog.debug(running)
alive = tuple(p.proc.is_alive() for p in procs)
if alive != running_prev:
print(running)
running_prev = alive
# send managerState
msg = messaging.new_message('managerState', valid=True)
msg.managerState.processes = [p.get_process_state_msg() for p in managed_processes.values()]
pm.send('managerState', msg)
# kick AGNOS power monitoring watchdog
try:
if sm.all_checks(['deviceState']):
with atomic_write("/var/tmp/power_watchdog", "w", overwrite=True) as f:
f.write(str(time.monotonic()))
except Exception:
pass
# Exit main loop when uninstall/shutdown/reboot is needed
shutdown = False
for param in ("DoUninstall", "DoShutdown", "DoReboot"):
if params.get_bool(param):
shutdown = True
params.put("LastManagerExitReason", f"{param} {datetime.datetime.now()}")
cloudlog.warning(f"Shutting down manager - {param} set")
if shutdown:
break
def main() -> None:
manager_init()
if os.getenv("PREPAREONLY") is not None:
return
# SystemExit on sigterm
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(1))
try:
manager_thread()
except Exception:
traceback.print_exc()
sentry.capture_exception()
finally:
manager_cleanup()
params = Params()
if params.get_bool("DoUninstall"):
cloudlog.warning("uninstalling")
HARDWARE.uninstall()
elif params.get_bool("DoReboot"):
cloudlog.warning("reboot")
HARDWARE.reboot()
elif params.get_bool("DoShutdown"):
cloudlog.warning("shutdown")
HARDWARE.shutdown()
if __name__ == "__main__":
if not (os.getenv("SIMULATION") and sys.platform == "darwin"):
unblock_stdout()
try:
main()
except KeyboardInterrupt:
print("got CTRL-C, exiting")
except Exception:
add_file_handler(cloudlog)
cloudlog.exception("Manager failed to start")
try:
managed_processes['ui'].stop()
except Exception:
pass
# Show last 3 lines of traceback
error = traceback.format_exc(-3)
error = "Manager failed to start\n\n" + error
with TextWindow(error) as t:
t.wait_for_exit()
raise
# manual exit because we are forked
sys.exit(0)

View File

@@ -0,0 +1,365 @@
import importlib
import os
import signal
import time
import subprocess
from pathlib import Path
from collections.abc import Callable, ValuesView
from abc import ABC, abstractmethod
from multiprocessing import Process
from setproctitle import setproctitle
from iqpilot.cereal import car, log
import iqpilot.cereal.messaging as messaging
import iqpilot.system.sentry as sentry
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
MAX_CRASH_BACKOFF = 300.0
CRASH_RESET_TIME = 60.0
CRASH_LOOP_THRESHOLD = 6
try:
from iqpilot.system.proprietary_runtime.runtime_paths import preferred_runner_path
except ModuleNotFoundError:
_VERIFIED_RUNNER_PATH = Path("/usr/libexec/iqpilot/iqpilot_bundle_runner")
_FALLBACK_RUNNER_PATH = Path("/data/openpilot/iqpilot/system/proprietary_runtime/iqpilot_bundle_runner")
def preferred_runner_path() -> Path:
if _VERIFIED_RUNNER_PATH.is_file() and os.access(_VERIFIED_RUNNER_PATH, os.X_OK):
return _VERIFIED_RUNNER_PATH
if os.getenv("IQPILOT_ALLOW_DEV_FALLBACKS") == "1" and _FALLBACK_RUNNER_PATH.is_file():
return _FALLBACK_RUNNER_PATH
return _VERIFIED_RUNNER_PATH
def launcher(proc: str, name: str) -> None:
try:
# import the process
mod = importlib.import_module(proc)
# rename the process
setproctitle(proc)
# create new context since we forked
messaging.reset_context()
# add daemon name tag to logs
cloudlog.bind(daemon=name)
sentry.set_tag("daemon", name)
# exec the process
mod.main()
except KeyboardInterrupt:
cloudlog.warning(f"child {proc} got SIGINT")
except Exception:
# can't install the crash handler because sys.excepthook doesn't play nice
# with threads, so catch it here.
sentry.capture_exception()
raise
def nativelauncher(pargs: list[str], cwd: str, name: str) -> None:
os.environ['MANAGER_DAEMON'] = name
# exec the process
os.chdir(cwd)
os.environ['PWD'] = cwd
os.execvp(pargs[0], pargs)
def join_process(process: Process, timeout: float) -> None:
# Process().join(timeout) will hang due to a python 3 bug: https://bugs.python.org/issue28382
# We have to poll the exitcode instead
t = time.monotonic()
while time.monotonic() - t < timeout and process.exitcode is None:
time.sleep(0.001)
class ManagerProcess(ABC):
daemon = False
sigkill = False
should_run: Callable[[bool, Params, car.CarParams], bool]
proc: Process | None = None
enabled = True
name = ""
shutting_down = False
restart_if_crash = False
crash_count = 0
last_restart_time = 0.0
last_alive_time = 0.0
crash_loop_logged = False
@abstractmethod
def prepare(self) -> None:
pass
@abstractmethod
def start(self) -> None:
pass
def restart(self) -> None:
self.stop(sig=signal.SIGKILL)
self.start()
def stop(self, retry: bool = True, block: bool = True, sig: signal.Signals | None = None, timeout: float = 5) -> int | None:
if self.proc is None:
return None
if self.proc.exitcode is None:
if not self.shutting_down:
cloudlog.info(f"killing {self.name}")
if sig is None:
sig = signal.SIGKILL if self.sigkill else signal.SIGINT
self.signal(sig)
self.shutting_down = True
if not block:
return None
join_process(self.proc, timeout)
# If process failed to die send SIGKILL
if self.proc.exitcode is None and retry:
cloudlog.info(f"killing {self.name} with SIGKILL")
self.signal(signal.SIGKILL)
self.proc.join()
ret = self.proc.exitcode
cloudlog.info(f"{self.name} is dead with {ret}")
if self.proc.exitcode is not None:
self.shutting_down = False
self.proc = None
return ret
def signal(self, sig: int) -> None:
if self.proc is None:
return
# Don't signal if already exited
if self.proc.exitcode is not None and self.proc.pid is not None:
return
# Can't signal if we don't have a pid
if self.proc.pid is None:
return
cloudlog.info(f"sending signal {sig} to {self.name}")
os.kill(self.proc.pid, sig)
def get_process_state_msg(self):
state = log.ManagerState.ProcessState.new_message()
state.name = self.name
if self.proc:
state.running = self.proc.is_alive()
state.shouldBeRunning = self.proc is not None and not self.shutting_down
state.pid = self.proc.pid or 0
state.exitCode = self.proc.exitcode or 0
return state
class NativeProcess(ManagerProcess):
def __init__(self, name, cwd, cmdline, should_run, enabled=True, sigkill=False, restart_if_crash=False):
self.name = name
self.cwd = cwd
self.cmdline = cmdline
self.should_run = should_run
self.enabled = enabled
self.sigkill = sigkill
self.launcher = nativelauncher
self.restart_if_crash = restart_if_crash
def prepare(self) -> None:
pass
def start(self) -> None:
# In case we only tried a non blocking stop we need to stop it before restarting
if self.shutting_down:
self.stop()
if self.proc is not None:
return
cwd = os.path.join(BASEDIR, self.cwd)
cloudlog.info(f"starting process {self.name}")
self.proc = Process(name=self.name, target=self.launcher, args=(self.cmdline, cwd, self.name))
self.proc.start()
self.shutting_down = False
def _normalize_bundle_modes(bundle: str) -> None:
import json
candidates = []
if env_root := os.environ.get("IQPILOT_PROPRIETARY_ROOT"):
candidates += [os.path.join(env_root, bundle), env_root]
candidates += [
os.path.join(BASEDIR, ".iqpilot", "bundles", bundle),
os.path.join(os.path.dirname(BASEDIR), ".iqpilot", "bundles", bundle),
os.path.join(BASEDIR, "artifacts", bundle),
]
root = next((c for c in candidates if os.path.isfile(os.path.join(c, "manifest.json"))), None)
if root is None:
return
try:
with open(os.path.join(root, "manifest.json")) as f:
manifest = json.load(f)
for rel, meta in manifest.items():
if not (isinstance(meta, dict) and "mode" in meta and "sha256" in meta):
continue
path = os.path.join(root, rel)
if os.path.isfile(path) and (os.stat(path).st_mode & 0o777) != meta["mode"]:
os.chmod(path, meta["mode"])
except Exception:
cloudlog.exception(f"failed to normalize bundle modes for {bundle}")
class BundleProcess(NativeProcess):
def __init__(self, name, bundle, entry, should_run, enabled=True, sigkill=False, restart_if_crash=False):
self.bundle = bundle
self.entry = entry
self.restart_if_crash = restart_if_crash
runner_path = preferred_runner_path()
runner_cmd = str(runner_path) if runner_path.is_absolute() else "./iqpilot_bundle_runner"
runner_cwd = ".iqpilot/runtime_root" if runner_path.is_absolute() else "system/proprietary_runtime"
super().__init__(
name=name,
cwd=runner_cwd,
cmdline=[
runner_cmd,
"--bundle", bundle,
"--mode", "python-module",
"--entry", entry,
"--daemon-name", name,
],
should_run=should_run,
enabled=enabled,
sigkill=sigkill,
)
def start(self) -> None:
if self.proc is None:
_normalize_bundle_modes(self.bundle)
super().start()
def stop(self, retry: bool = True, block: bool = True, sig: signal.Signals | None = None, timeout: float = 5) -> int | None:
return super().stop(retry=retry, block=block, sig=signal.SIGTERM if sig is None else sig, timeout=timeout)
class PythonProcess(ManagerProcess):
def __init__(self, name, module, should_run, enabled=True, sigkill=False, restart_if_crash=False):
self.name = name
self.module = module
self.should_run = should_run
self.enabled = enabled
self.sigkill = sigkill
self.launcher = launcher
self.restart_if_crash = restart_if_crash
def prepare(self) -> None:
if self.enabled:
cloudlog.info(f"preimporting {self.module}")
importlib.import_module(self.module)
def start(self) -> None:
# In case we only tried a non blocking stop we need to stop it before restarting
if self.shutting_down:
self.stop()
if self.proc is not None:
return
cloudlog.info(f"starting python {self.module}")
self.proc = Process(name=self.name, target=self.launcher, args=(self.module, self.name))
self.proc.start()
self.shutting_down = False
class DaemonProcess(ManagerProcess):
"""Python process that has to stay running across manager restart.
This is used for athena so you don't lose SSH access when restarting manager."""
def __init__(self, name, module, param_name, enabled=True):
self.name = name
self.module = module
self.param_name = param_name
self.enabled = enabled
self.params = None
@staticmethod
def should_run(started, params, CP):
return True
def prepare(self) -> None:
pass
def start(self) -> None:
if self.params is None:
self.params = Params()
pid = self.params.get(self.param_name)
if pid is not None:
try:
os.kill(int(pid), 0)
with open(f'/proc/{pid}/cmdline') as f:
if self.module in f.read():
# daemon is running
return
except (OSError, FileNotFoundError):
# process is dead
pass
cloudlog.info(f"starting daemon {self.name}")
proc = subprocess.Popen(['python', '-m', self.module],
stdin=open('/dev/null'),
stdout=open('/dev/null', 'w'),
stderr=open('/dev/null', 'w'),
preexec_fn=os.setpgrp)
self.params.put(self.param_name, proc.pid)
def stop(self, retry=True, block=True, sig=None) -> None:
pass
def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None, CP: car.CarParams=None,
not_run: list[str] | None=None) -> list[ManagerProcess]:
if not_run is None:
not_run = []
running = []
now = time.monotonic()
for p in procs:
if p.enabled and p.name not in not_run and p.should_run(started, params, CP):
if p.restart_if_crash and p.proc is not None and p.proc.is_alive():
p.last_alive_time = now
elif p.restart_if_crash and p.proc is not None:
# uptime, not time-since-restart: the latter also counts the backoff wait,
# which would reset the counter as soon as backoff exceeds CRASH_RESET_TIME
if p.last_alive_time - p.last_restart_time > CRASH_RESET_TIME:
p.crash_count = 0
p.crash_loop_logged = False
backoff = 0.0 if not p.crash_count else min(MAX_CRASH_BACKOFF, 2.0 ** (p.crash_count - 1))
if now - p.last_restart_time >= backoff:
p.crash_count += 1
p.last_restart_time = now
cloudlog.error(f'Restarting {p.name} (exitcode {p.proc.exitcode}) [crash {p.crash_count}]')
if p.crash_count >= CRASH_LOOP_THRESHOLD and not p.crash_loop_logged:
# never stop retrying: giving up on hardwared or ui is worse than restarting slowly
cloudlog.error(f'{p.name} is in a crash loop, backing off to {MAX_CRASH_BACKOFF}s between restarts')
p.crash_loop_logged = True
p.restart()
running.append(p)
else:
p.crash_count = 0
p.crash_loop_logged = False
p.last_alive_time = 0.0
p.stop(block=False)
for p in running:
p.start()
return running

View File

@@ -0,0 +1,258 @@
import os
import platform
from pathlib import Path
from iqpilot.cereal import car, custom
from iqpilot.common.params import Params
from iqpilot.system.hardware import HARDWARE, PC, TICI
from iqpilot.system.hardware.hw import Paths
from iqpilot.system.manager.process import PythonProcess, NativeProcess, BundleProcess
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected, resolve_backend, usbgpu_present
from iqpilot.selfdrive.iqmodeld.models.helpers import get_active_model_runner
from iqpilot.konn3kt.service_health import hephaestus_ready
def driverview(started: bool, params: Params, CP: car.CarParams) -> bool:
return started or params.get_bool("IsDriverViewEnabled")
def driver_monitoring(started: bool, params: Params, CP: car.CarParams) -> bool:
if os.path.exists('/tmp/lite_hw'):
return False
return driverview(started, params, CP)
def notcar(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and CP.notCar
def iscar(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not CP.notCar
def logging(started: bool, params: Params, CP: car.CarParams) -> bool:
run = (not CP.notCar) or not params.get_bool("DisableLogging")
return started and run and params.get_bool("DashcamEnabled")
def ublox_available() -> bool:
if HARDWARE.get_device_type() == "tizi" or os.path.exists('/tmp/lite_hw'):
return False
quectel_override = Path(Paths.persist_root()) / "comma" / "use-quectel-gps"
return os.path.exists('/dev/ttyHS0') and not quectel_override.exists()
def ublox(started: bool, params: Params, CP: car.CarParams) -> bool:
use_ublox = ublox_available()
if use_ublox != params.get_bool("UbloxAvailable"):
params.put_bool("UbloxAvailable", use_ublox)
return started and use_ublox
def joystick(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("JoystickDebugMode")
def not_joystick(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not params.get_bool("JoystickDebugMode")
def long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("LongitudinalManeuverMode")
def not_long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not params.get_bool("LongitudinalManeuverMode")
def lat_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("LateralManeuverMode")
def not_lat_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not params.get_bool("LateralManeuverMode")
def qcomgps(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not ublox_available()
def always_run(started: bool, params: Params, CP: car.CarParams) -> bool:
return True
def android_nav(started: bool, params: Params, CP: car.CarParams) -> bool:
return params.get_bool("IQAndroidNav")
def only_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started
def navd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("NavigationEnabled")
def navrenderd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("NavigationEnabled") and params.get_bool("OnScreenNavigation")
def navincidentd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("NavigationEnabled") and bool(params.get("WazePoliceApiKey")) and (
params.get_int("WazePoliceAlertMode") > 0 or params.get_bool("WazePoliceShadow")
)
def iqmapd_needed(params: Params) -> bool:
return (
params.get_bool("IQRoadNameOverlay")
or params.get_bool("ShowSpeedLimits")
or params.get_bool("SpeedLimitController")
or params.get_bool("EnableSpeedLimitControl")
or params.get_bool("EnableSpeedLimitPredicative")
or params.get_bool("MapCurveSpeedController")
or params.get_bool("VisionCurveSpeedController")
)
def iqmapd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("NavigationEnabled") and iqmapd_needed(params)
def mapd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and iqmapd_needed(params)
def constructiond_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("ConstructionZoneAssist")
def iqvd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool:
# held for 1.0d: iqvd runs a detector per frame and the added load is not
# something 1.0c needs to carry. re-enable by restoring the param check.
return False
def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool:
return not started
def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
# Konn3kt Live View: hephaestusd sets IsLiveStreaming when a viewer connects, so the
# manager brings up the stream encoder (and camerad/webrtcd when offroad) and tears them
# down cleanly when the session ends — no subprocess management inside hephaestusd.
return params.get_bool("IsLiveStreaming")
def canlive(started: bool, params: Params, CP: car.CarParams) -> bool:
# Remote live CAN debugging via konn3kt. hephaestusd sets CanLiveStreaming when a viewer
# connects (startCanLive) and clears it when the last one leaves (stopCanLive), so canlived
# runs only during an active debug session — no idle connection or battery cost otherwise.
return params.get_bool("CanLiveStreaming")
def is_tinygrad_model(started, params, CP: car.CarParams) -> bool:
"""Check if the active model runner is tinygrad."""
return bool(get_active_model_runner(params, not started) == custom.IQModelManager.Runner.tinygrad)
def _egpu_present(params) -> bool:
if params.get_bool("IQEgpuDisabled"):
return False
return usbgpu_present()
def emac_enabled(started, params, CP: car.CarParams) -> bool:
return resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), _egpu_present(params)) == "emac"
def egpu_enabled(started, params, CP: car.CarParams) -> bool:
return (resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), _egpu_present(params)) == "egpu"
and _egpu_present(params))
def egpu_prefetch_enabled(started, params, CP: car.CarParams) -> bool:
if params.get_bool("IQEgpuDisabled"):
return False
return resolve_backend(params.get_bool("IQEmacEnabled"), True, _egpu_present(params)) == "egpu"
def big_model_enabled(started, params, CP: car.CarParams) -> bool:
return params.get_bool("IQEmacEnabled") or egpu_selected(params)
def hephaestus_ready_shim(started, params, CP: car.CarParams) -> bool:
return hephaestus_ready(params)
def not_low_power(started: bool, params: Params, CP: car.CarParams) -> bool:
# FastSleep deep standby: heavy processes are shed offroad while DevicePowerState is low_power
return started or params.get("DevicePowerState") != "low_power"
def iquploaderd_ready(started: bool, params: Params, CP: car.CarParams) -> bool:
if not params.get_bool("OnroadUploads"):
return only_offroad(started, params, CP)
return always_run(started, params, CP)
def or_(*fns):
return lambda *args: any(fn(*args) for fn in fns)
def and_(*fns):
return lambda *args: all(fn(*args) for fn in fns)
procs = [
NativeProcess("loggerd", "iqpilot/system/loggerd", ["./loggerd"], logging),
NativeProcess("encoderd", "iqpilot/system/loggerd", ["./encoderd"], only_onroad),
NativeProcess("stream_encoderd", "iqpilot/system/loggerd", ["./encoderd", "--stream"], or_(notcar, livestream)),
PythonProcess("logmessaged", "iqpilot.system.logmessaged", always_run, restart_if_crash=True),
NativeProcess("camerad", "iqpilot/system/camerad", ["./camerad"], or_(driverview, livestream), restart_if_crash=True),
PythonProcess("proclogd", "iqpilot.system.proclogd", only_onroad, enabled=platform.system() != "Darwin"),
PythonProcess("journald", "iqpilot.system.journald", only_onroad, platform.system() != "Darwin"),
PythonProcess("micd", "iqpilot.system.micd", or_(iscar, livestream)),
PythonProcess("timed", "iqpilot.system.timed", always_run, enabled=not PC),
PythonProcess("androidd", "iqpilot.system.android.androidd", android_nav, enabled=TICI, restart_if_crash=True),
PythonProcess("dmonitoringmodeld", "iqpilot.selfdrive.dmonitoringmodeld.dmonitoringmodeld", driver_monitoring, enabled=not PC),
PythonProcess("sensord", "iqpilot.system.sensord.sensord", only_onroad, enabled=not PC),
PythonProcess("ui", "iqpilot.selfdrive.ui.ui", not_low_power, restart_if_crash=True),
PythonProcess("soundd", "iqpilot.selfdrive.ui.soundd", driverview),
PythonProcess("locationd", "iqpilot.selfdrive.locationd.locationd", only_onroad),
NativeProcess("_pandad", "iqpilot/selfdrive/pandad", ["./pandad"], always_run, enabled=False),
PythonProcess("calibrationd", "iqpilot.selfdrive.locationd.calibrationd", only_onroad),
PythonProcess("controlsd", "iqpilot.selfdrive.controls.controlsd", and_(not_joystick, iscar)),
PythonProcess("joystickd", "iqpilot.tools.joystick.joystickd", or_(joystick, notcar)),
PythonProcess("selfdrived", "iqpilot.selfdrive.selfdrived.selfdrived", only_onroad),
PythonProcess("card", "iqpilot.selfdrive.car.card", only_onroad),
PythonProcess("deleter", "iqpilot.system.loggerd.deleter", always_run),
PythonProcess("dmonitoringd", "iqpilot.selfdrive.monitoring.dmonitoringd", driver_monitoring, enabled=not PC),
PythonProcess("qcomgpsd", "iqpilot.system.qcomgpsd.qcomgpsd", qcomgps, enabled=TICI),
PythonProcess("pandad", "iqpilot.selfdrive.pandad.pandad", always_run),
PythonProcess("estimatord", "iqpilot.selfdrive.locationd.estimatord", only_onroad),
PythonProcess("ubloxd", "iqpilot.system.ubloxd.ubloxd", ublox, enabled=TICI),
PythonProcess("pigeond", "iqpilot.system.ubloxd.pigeond", ublox, enabled=TICI),
PythonProcess("plannerd", "iqpilot.selfdrive.controls.plannerd", not_long_maneuver),
PythonProcess("maneuversd", "iqpilot.tools.maneuvers.longitudinal_maneuversd", long_maneuver),
PythonProcess("lateral_maneuversd", "iqpilot.tools.maneuvers.lateral_maneuversd", lat_maneuver),
PythonProcess("radard", "iqpilot.selfdrive.controls.radard", only_onroad),
PythonProcess("hardwared", "iqpilot.system.hardware.hardwared", always_run, restart_if_crash=True),
PythonProcess("tombstoned", "iqpilot.system.tombstoned", always_run, enabled=not PC),
PythonProcess("updated", "iqpilot.system.updated.updated", and_(only_offroad, not_low_power), enabled=not PC),
BundleProcess("iquploaderd", "iqpilot_hephaestusd_private", "iqpilot_private.konn3kt.uploaderd.iquploaderd",
and_(iquploaderd_ready, not_low_power), restart_if_crash=True),
PythonProcess("feedbackd", "iqpilot.selfdrive.ui.feedback.feedbackd", and_(only_onroad, not_lat_maneuver)),
# debug procs
NativeProcess("bridge", "iqpilot/cereal/messaging", ["./bridge"], notcar),
PythonProcess("webrtcd", "iqpilot.system.webrtc.webrtcd", or_(iscar, livestream)),
PythonProcess("canlived", "iqpilot.konn3kt.canlive.canlived", canlive),
]
# iqpilot
procs += [
# Models
BundleProcess("models_manager", "iqpilot_model_selector_private", "iqpilot_private.models.manager", and_(only_offroad, not_low_power)),
NativeProcess("iqmodeld", "iqpilot/selfdrive/iqmodeld", ["./iqmodeld"], and_(only_onroad, is_tinygrad_model), restart_if_crash=True),
# big-model backends: iqmodeld self-demotes to the small channel worker when
# either backend is enabled; the selector publishes, and exactly one big
# worker (Mac or eGPU, eMac wins) feeds the BIG channel
PythonProcess("modeld_selector", "iqpilot.selfdrive.iqmodeld.modeld_selector",
and_(only_onroad, and_(is_tinygrad_model, big_model_enabled)), restart_if_crash=True),
BundleProcess("maciqmodeld", "iqpilot_emac_private", "iqpilot_private.emac.maciqmodeld",
and_(only_onroad, and_(is_tinygrad_model, emac_enabled)), restart_if_crash=True),
PythonProcess("iqegpumodeld", "iqpilot.selfdrive.iqmodeld.iqegpumodeld",
and_(only_onroad, and_(is_tinygrad_model, egpu_enabled)), restart_if_crash=True),
PythonProcess("egpu_prefetch", "iqpilot.selfdrive.iqmodeld.egpu_prefetch",
and_(only_offroad, and_(is_tinygrad_model, egpu_prefetch_enabled)), restart_if_crash=True),
BundleProcess("backup_manager_k3", "iqpilot_hephaestusd_private", "iqpilot_private.konn3kt.backups.backup_orchestrator",
and_(only_offroad, hephaestus_ready_shim, not_low_power)),
BundleProcess("navd", "iqpilot_navd_private", "iqpilot_private.navd.navd", navd_onroad, restart_if_crash=True),
BundleProcess("navincidentd", "iqpilot_navd_private", "iqpilot_private.navd.navincidentd", navincidentd_onroad, restart_if_crash=True),
BundleProcess("navrenderd", "iqpilot_navd_private", "iqpilot_private.navd.navrenderd", navrenderd_onroad, restart_if_crash=True),
BundleProcess("iqmapd", "iqpilot_navd_private", "iqpilot_private.navd.iqmapd", iqmapd_onroad, restart_if_crash=True),
# work-zone detector for Speed Limit Assist
PythonProcess("constructiond", "iqpilot.selfdrive.constructiond", constructiond_onroad, restart_if_crash=True),
# iqvd: vision vehicle detector for UI ambient track dots
BundleProcess("iqvd", "iqpilot_iqvd_private", "iqpilot_private.iqvd.iqvd", iqvd_onroad, restart_if_crash=True),
# mapd
NativeProcess("mapd", "iqpilot/third_party/mapd_pfeiferj", ["./mapd"], mapd_onroad, restart_if_crash=True),
PythonProcess("mapd_manager", "iqpilot.iq_maps.orchestrator", and_(only_offroad, not_low_power)),
# locationd
NativeProcess("iqlocd", "iqpilot/selfdrive/iqlocd", ["./iqlocd"], only_onroad, restart_if_crash=True),
]
managed_processes = {p.name: p for p in procs}

Some files were not shown because too many files have changed in this diff Show More