IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
0
iqpilot/system/__init__.py
Normal file
0
iqpilot/system/__init__.py
Normal file
0
iqpilot/system/android/__init__.py
Normal file
0
iqpilot/system/android/__init__.py
Normal file
200
iqpilot/system/android/androidd.py
Normal file
200
iqpilot/system/android/androidd.py
Normal 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()
|
||||
37
iqpilot/system/ble-transportd.service
Normal file
37
iqpilot/system/ble-transportd.service
Normal 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
|
||||
0
iqpilot/system/camerad/__init__.py
Normal file
0
iqpilot/system/camerad/__init__.py
Normal file
BIN
iqpilot/system/camerad/camerad
Executable file
BIN
iqpilot/system/camerad/camerad
Executable file
Binary file not shown.
21
iqpilot/system/camerad/cameras/nv12_info.py
Normal file
21
iqpilot/system/camerad/cameras/nv12_info.py
Normal 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
|
||||
132
iqpilot/system/camerad/snapshot.py
Executable file
132
iqpilot/system/camerad/snapshot.py
Executable 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")
|
||||
2
iqpilot/system/camerad/test/.gitignore
vendored
Normal file
2
iqpilot/system/camerad/test/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
jpegs/
|
||||
test_ae_gray
|
||||
16
iqpilot/system/camerad/test/debug.sh
Executable file
16
iqpilot/system/camerad/test/debug.sh
Executable 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
|
||||
13
iqpilot/system/camerad/test/icp_debug.sh
Executable file
13
iqpilot/system/camerad/test/icp_debug.sh
Executable 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
|
||||
2
iqpilot/system/camerad/test/intercept.sh
Executable file
2
iqpilot/system/camerad/test/intercept.sh
Executable 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
|
||||
9
iqpilot/system/camerad/test/stress_restart.sh
Executable file
9
iqpilot/system/camerad/test/stress_restart.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
cd ..
|
||||
while :; do
|
||||
./camerad &
|
||||
pid="$!"
|
||||
sleep 2
|
||||
kill -2 $pid
|
||||
wait $pid
|
||||
done
|
||||
217
iqpilot/system/camerad/test/test_camerad.py
Normal file
217
iqpilot/system/camerad/test/test_camerad.py
Normal 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}'
|
||||
51
iqpilot/system/camerad/test/test_exposure.py
Normal file
51
iqpilot/system/camerad/test/test_exposure.py
Normal 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
|
||||
34
iqpilot/system/flockd.service
Normal file
34
iqpilot/system/flockd.service
Normal 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
|
||||
22
iqpilot/system/hardware/__init__.py
Normal file
22
iqpilot/system/hardware/__init__.py
Normal 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")
|
||||
228
iqpilot/system/hardware/base.py
Normal file
228
iqpilot/system/hardware/base.py
Normal 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
|
||||
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# eGPU dock bring-up runbook
|
||||
|
||||
Our flasher is a port of comma's known-working one, byte-identical firmware
|
||||
bundle, but it has never touched real hardware. Order matters: everything
|
||||
read-only first, evidence at every step.
|
||||
|
||||
Run everything as root from the repo root on the device, dock on the USB-C
|
||||
port, car ignition off.
|
||||
|
||||
## 1. Read-only probe (safe, run first, share the output)
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/dock_probe.py
|
||||
|
||||
Expected on a dock previously flashed by stock openpilot: product matches the
|
||||
bundled `custom ed4e39b7-CLEAN`, USB3 speed, PCIe link L0, stable config read.
|
||||
Any other result: stop and send the output before proceeding.
|
||||
|
||||
## 2. Flash-path validation (writes, but writes the same bytes)
|
||||
|
||||
A stock-flashed dock already runs our exact bundled firmware, so the
|
||||
no-op path proves version detection:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py
|
||||
|
||||
Expected: "firmware matches" and no write. Then exercise the full write path
|
||||
by reflashing the identical image:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py --force
|
||||
|
||||
This backs up the per-unit config page to /data/egpu_dock_config/ first and
|
||||
verifies every sector; identical bytes make it the lowest-risk possible
|
||||
full-path test. Re-run step 1 after; product string and config sha must be
|
||||
unchanged.
|
||||
|
||||
## 3. Runtime
|
||||
|
||||
Set `IQEgpuEnabled`, go onroad (bench is fine), and confirm iqegpumodeld
|
||||
downloads/compiles and the selector reports UsbGpu* status. The runtime gate
|
||||
requires the exact bundled firmware product string, so a dock that failed
|
||||
step 2 will be treated as absent by design.
|
||||
|
||||
## If anything goes wrong
|
||||
|
||||
The dock falling back to the ROM bootloader (product "USB 3.2 PCIe
|
||||
TinyEnclosure" or AS2462*) is recoverable: flash.py handles ROM recovery, and
|
||||
the config backup from step 2 is on disk. Do not improvise register writes;
|
||||
capture output and stop.
|
||||
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock.flash import (
|
||||
Flash, RomFallback, bundled_version, find_dock, in_rom_bootloader, link_up, stable_read,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock enumerated")
|
||||
return 1
|
||||
print(f"dock at {path}")
|
||||
print(f" vid:pid {vid_pid[0]}:{vid_pid[1]}")
|
||||
print(f" product {product!r}")
|
||||
print(f" bundled {bundled_version()!r}")
|
||||
print(f" match {product == bundled_version()}")
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
print(f" usb speed {speed} Mbps ({'USB3' if speed >= 5000 else 'USB2 - register reads capped at 64B'})")
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
print(" state ROM bootloader (config page lost or firmware invalid)")
|
||||
return 2
|
||||
print(f" pcie link {'L0 (trained)' if link_up() else 'not trained'}")
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
flash.connect()
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
print(f" config sha256={hashlib.sha256(config).hexdigest()[:16]} "
|
||||
f"(stable over 3 reads, {sum(1 for b in config if b != 0xFF)} non-blank bytes)")
|
||||
except (RomFallback, OSError, RuntimeError, TimeoutError) as e:
|
||||
print(f" config read failed: {type(e).__name__}: {e}")
|
||||
return 3
|
||||
finally:
|
||||
flash.close()
|
||||
print("all read-only checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
Binary file not shown.
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
VID_PIDS = (("add1", "0001"), ("3801", "0001"))
|
||||
ROM_VID_PIDS = (("174c", "2464"), ("174c", "2463"))
|
||||
ROM_PRODUCT = "USB 3.2 PCIe TinyEnclosure"
|
||||
FIRMWARE_PATH = Path(__file__).with_name("firmware_wrapped.bin")
|
||||
CONFIG_DIR = "/data/egpu_dock_config"
|
||||
LEGACY_CONFIG_DIR = "/data/chestnut_config"
|
||||
PM_PATHS = ("/sys/bus/platform/devices/a800000.ssusb", "/sys/bus/platform/devices/a600000.ssusb",
|
||||
"/sys/bus/usb/devices/usb4")
|
||||
VBUS_PATH = "/sys/kernel/debug/regulator/smb2-vbus/enable"
|
||||
IMAGE_OFFSET = 0x100
|
||||
SECTOR, PAGE = 4096, 128
|
||||
MAX_REGISTER_READ_SIZE = 255
|
||||
MAX_CODE_SIZE = 0x10000
|
||||
FLASH_BUDGET = 600.0
|
||||
USBDEVFS_CONTROL = 0xC0185500
|
||||
USBDEVFS_BULK = 0xC0185502
|
||||
USBDEVFS_SETINTERFACE = 0x80085504
|
||||
USBDEVFS_SETCONFIGURATION = 0x80045505
|
||||
USBDEVFS_CLAIMINTERFACE = 0x8004550F
|
||||
USBDEVFS_RESET = 0x5514
|
||||
USBDEVFS_CLEAR_HALT = 0x80045515
|
||||
|
||||
_deadline = float("inf")
|
||||
|
||||
|
||||
def check_budget():
|
||||
if time.monotonic() > _deadline:
|
||||
raise TimeoutError(f"flash did not converge within {FLASH_BUDGET:g}s")
|
||||
|
||||
|
||||
class Ctrl(ctypes.Structure):
|
||||
_fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8),
|
||||
("value", ctypes.c_uint16), ("index", ctypes.c_uint16),
|
||||
("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32),
|
||||
("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class Bulk(ctypes.Structure):
|
||||
_fields_ = [("ep", ctypes.c_uint), ("len", ctypes.c_uint),
|
||||
("timeout", ctypes.c_uint), ("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class RomFallback(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_dock():
|
||||
found = []
|
||||
for d in glob.glob("/sys/bus/usb/devices/*"):
|
||||
try:
|
||||
with open(d + "/idVendor") as fv, open(d + "/idProduct") as fp:
|
||||
vid_pid = (fv.read().strip(), fp.read().strip())
|
||||
if vid_pid in VID_PIDS + ROM_VID_PIDS:
|
||||
with open(d + "/product") as fpr:
|
||||
found.append((d, vid_pid, fpr.read().strip()))
|
||||
except OSError:
|
||||
pass
|
||||
if len(found) > 1:
|
||||
raise RuntimeError(f"expected one eGPU dock, found {len(found)}")
|
||||
return found[0] if found else (None, None, None)
|
||||
|
||||
|
||||
def in_rom_bootloader(vid_pid, product):
|
||||
return vid_pid in ROM_VID_PIDS or product == ROM_PRODUCT or (product or "").startswith("AS2462")
|
||||
|
||||
|
||||
def disable_runtime_pm(path):
|
||||
control = os.path.join(path, "power/control")
|
||||
if not os.path.exists(control):
|
||||
return
|
||||
with open(control, "w") as f:
|
||||
f.write("on\n")
|
||||
with open(control) as fh:
|
||||
applied = fh.read().strip()
|
||||
if applied != "on":
|
||||
raise RuntimeError(f"could not disable USB runtime PM: {control}")
|
||||
delay = os.path.join(path, "power/autosuspend_delay_ms")
|
||||
if os.path.exists(delay):
|
||||
with open(delay, "w") as f:
|
||||
f.write("-1\n")
|
||||
|
||||
|
||||
def unbind_drivers(path):
|
||||
for interface in glob.glob(path + ":*"):
|
||||
driver = interface + "/driver"
|
||||
if os.path.islink(driver):
|
||||
with open(os.path.realpath(driver) + "/unbind", "w") as f:
|
||||
f.write(os.path.basename(interface))
|
||||
|
||||
|
||||
def open_device(path):
|
||||
with open(path + "/busnum") as fb, open(path + "/devnum") as fd_:
|
||||
bus, dev = int(fb.read()), int(fd_.read())
|
||||
return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR)
|
||||
|
||||
|
||||
def link_up() -> bool:
|
||||
try:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
return False
|
||||
fd = open_device(path)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0x40, 0xF3, 1, 0, 0, 2000, None))
|
||||
buf = (ctypes.c_ubyte * 1)()
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf[0] == 0x78
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def claim_interface(path, setup=False):
|
||||
disable_runtime_pm(path)
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETCONFIGURATION, struct.pack("I", 1))
|
||||
fcntl.ioctl(fd, USBDEVFS_CLAIMINTERFACE, struct.pack("I", 0))
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETINTERFACE, struct.pack("II", 0, 0))
|
||||
except OSError as e:
|
||||
os.close(fd)
|
||||
if e.errno == errno.EBUSY:
|
||||
raise RuntimeError("eGPU dock is in use, stop the model/GPU processes before flashing") from e
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
class Flash:
|
||||
def __init__(self):
|
||||
self.fd = -1
|
||||
self.max_register_read_size = MAX_REGISTER_READ_SIZE
|
||||
|
||||
def close(self):
|
||||
if self.fd >= 0:
|
||||
os.close(self.fd)
|
||||
self.fd = -1
|
||||
|
||||
def connect(self, timeout=5.0):
|
||||
self.close()
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
path, vid_pid, product = find_dock()
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
raise RomFallback("eGPU dock fell back to the ROM bootloader")
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
except (OSError, ValueError):
|
||||
speed = 0
|
||||
self.max_register_read_size = 64 if speed < 5000 else MAX_REGISTER_READ_SIZE
|
||||
self.fd = claim_interface(path)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"eGPU dock did not enumerate within {timeout:g}s")
|
||||
|
||||
def reg_write(self, addr, value):
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0x40, 0xE5, addr & 0xFFFF, value & 0xFFFF, 0, 2000, None))
|
||||
|
||||
def reg_read(self, addr, length=1):
|
||||
buf = (ctypes.c_ubyte * length)()
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0xC0, 0xE4, addr & 0xFFFF, 0, length, 2000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return bytes(buf)
|
||||
|
||||
def write_buffer(self, data):
|
||||
for i, value in enumerate(data):
|
||||
self.reg_write(0x7000 + i, value)
|
||||
|
||||
def wait_controller(self, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.reg_read(0xC8A9)[0] & 1:
|
||||
return
|
||||
raise TimeoutError("flash controller timeout")
|
||||
|
||||
def transaction(self, command, addr=0, length=0, addr_len=0x07, mode=0):
|
||||
for reg, value in ((0xC8AD, mode), (0xC8AE, 0), (0xC8AF, 0), (0xC8AA, command), (0xC8AC, addr_len),
|
||||
(0xC8A1, addr), (0xC8A2, addr >> 8), (0xC8AB, addr >> 16), (0xC8A3, length >> 8), (0xC8A4, length)):
|
||||
self.reg_write(reg, value & 0xFF)
|
||||
self.reg_write(0xC8A9, 1)
|
||||
self.wait_controller()
|
||||
for _ in range(4):
|
||||
self.reg_write(0xC8AD, 0)
|
||||
|
||||
def write_enable(self):
|
||||
for reg, value in ((0xC8AD, 0), (0xC8AA, 0x06), (0xC8AC, 0x04), (0xC8A3, 0), (0xC8A4, 0), (0xC8A9, 1)):
|
||||
self.reg_write(reg, value)
|
||||
self.wait_controller()
|
||||
|
||||
def status(self):
|
||||
self.transaction(0x05, length=1, addr_len=0x04)
|
||||
return self.reg_read(0x7000)[0]
|
||||
|
||||
def wait_write_done(self, timeout=10.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.status() & 1:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise TimeoutError("SPI flash WIP timeout")
|
||||
|
||||
def init(self):
|
||||
self.reg_write(0xCC33, 0x04)
|
||||
self.reg_write(0xCA81, self.reg_read(0xCA81)[0] | 1)
|
||||
self.reg_write(0xC805, 0x02)
|
||||
self.reg_write(0xC8A6, 0x04)
|
||||
for _ in range(5):
|
||||
self.write_enable()
|
||||
self.write_buffer(bytes(4))
|
||||
self.transaction(0x01, length=1, addr_len=0x04, mode=1)
|
||||
time.sleep(0.01)
|
||||
if not self.status() & 0x1C:
|
||||
return
|
||||
raise RuntimeError("could not clear SPI block protection")
|
||||
|
||||
def read(self, addr, length):
|
||||
out = bytearray()
|
||||
while len(out) < length:
|
||||
n = min(4096, length - len(out))
|
||||
self.transaction(0x03, addr + len(out), max(4096, n))
|
||||
for off in range(0, n, self.max_register_read_size):
|
||||
out += self.reg_read(0x7000 + off, min(self.max_register_read_size, n - off))
|
||||
return bytes(out)
|
||||
|
||||
def erase_sector(self, addr):
|
||||
self.write_enable()
|
||||
self.transaction(0x20, addr)
|
||||
self.wait_write_done()
|
||||
|
||||
def program(self, addr, data):
|
||||
self.write_buffer(data + bytes((-len(data)) % 4))
|
||||
self.write_enable()
|
||||
self.transaction(0x02, addr, len(data), mode=1)
|
||||
self.wait_write_done()
|
||||
|
||||
|
||||
def validate_image(data):
|
||||
if len(data) < 10:
|
||||
raise ValueError("wrapped firmware is too short")
|
||||
body_len = int.from_bytes(data[:4], "little")
|
||||
if body_len > MAX_CODE_SIZE:
|
||||
raise ValueError(f"wrapped firmware body exceeds {MAX_CODE_SIZE} bytes")
|
||||
if len(data) != body_len + 10 or data[4 + body_len] != 0xA5:
|
||||
raise ValueError("invalid wrapped firmware length or magic")
|
||||
body = data[4:4 + body_len]
|
||||
if data[5 + body_len] != sum(body) & 0xFF:
|
||||
raise ValueError("invalid wrapped firmware checksum")
|
||||
if data[6 + body_len:] != zlib.crc32(body).to_bytes(4, "little"):
|
||||
raise ValueError("invalid wrapped firmware CRC")
|
||||
|
||||
|
||||
def image_product(image):
|
||||
match = re.search(rb"custom [0-9a-f]{8}-CLEAN", image)
|
||||
if match is None:
|
||||
raise ValueError("no product string in wrapped firmware")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
def reconnect(flash):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
check_budget()
|
||||
try:
|
||||
flash.connect()
|
||||
flash.init()
|
||||
return
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"waiting for eGPU dock (attempt {attempt}): {e}", flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def with_retries(flash, label, operation):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
return operation()
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
check_budget()
|
||||
print(f"{label} attempt {attempt}: {e}", flush=True)
|
||||
reconnect(flash)
|
||||
|
||||
|
||||
def stable_read(flash, addr, length, count=2):
|
||||
def read():
|
||||
reads = [flash.read(addr, length) for _ in range(count)]
|
||||
if any(x != reads[0] for x in reads[1:]):
|
||||
raise RuntimeError(f"unstable flash read at 0x{addr:05x}")
|
||||
return reads[0]
|
||||
return with_retries(flash, f"read 0x{addr:05x}", read)
|
||||
|
||||
|
||||
def program_sector(flash, addr, target):
|
||||
def program():
|
||||
flash.erase_sector(addr)
|
||||
if flash.read(addr, SECTOR) != bytes([0xFF]) * SECTOR:
|
||||
raise RuntimeError("sector erase verification failed")
|
||||
for off in range(0, SECTOR, PAGE):
|
||||
chunk = target[off:off + PAGE]
|
||||
if chunk != bytes([0xFF]) * len(chunk):
|
||||
flash.program(addr + off, chunk)
|
||||
if flash.read(addr + off, len(chunk)) != chunk:
|
||||
raise RuntimeError(f"page verify failed at 0x{addr + off:05x}")
|
||||
if flash.read(addr, SECTOR) != target:
|
||||
raise RuntimeError("sector verification failed")
|
||||
with_retries(flash, f"sector 0x{addr:05x}", program)
|
||||
|
||||
|
||||
def config_path():
|
||||
return os.path.join(CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def legacy_config_path():
|
||||
return os.path.join(LEGACY_CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def saved_config(path, data):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError as e:
|
||||
with open(path, "rb") as fh:
|
||||
backup = fh.read()
|
||||
if len(backup) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {path}") from e
|
||||
if backup != data:
|
||||
print(f"restoring config from {path}", flush=True)
|
||||
return backup
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
return data
|
||||
|
||||
|
||||
def rom_write(image, config):
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock disappeared before recovery")
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_RESET)
|
||||
finally:
|
||||
os.close(fd)
|
||||
time.sleep(3)
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock did not re-enumerate after reset")
|
||||
fd = claim_interface(path, setup=True)
|
||||
for ep in (0x02, 0x81):
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", ep))
|
||||
tag = 0
|
||||
|
||||
def bulk(ep, payload, timeout):
|
||||
buf = ctypes.create_string_buffer(bytes(payload), len(payload))
|
||||
fcntl.ioctl(fd, USBDEVFS_BULK, Bulk(ep, len(payload), timeout, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf.raw
|
||||
|
||||
def cmd(cdb, data=b"", timeout=30000):
|
||||
nonlocal tag
|
||||
tag += 1
|
||||
bulk(0x02, struct.pack("<IIIBBB16s", 0x43425355, tag, len(data), 0, 0, len(cdb), cdb), timeout)
|
||||
if data:
|
||||
bulk(0x02, data, timeout)
|
||||
try:
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EPIPE:
|
||||
raise
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", 0x81))
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
if csw[:4] != b"USBS" or csw[12] != 0:
|
||||
raise RuntimeError(f"ROM flash command {cdb[0]:02x} {cdb[1]:02x} failed")
|
||||
|
||||
print("recovering from the ROM bootloader", flush=True)
|
||||
try:
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 0), config[:0x80])
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 1), config[0x80:])
|
||||
cmd(struct.pack(">BBI", 0xE3, 0x50, min(len(image), 0xFF00)), image[:0xFF00])
|
||||
if len(image) > 0xFF00:
|
||||
cmd(struct.pack(">BBI", 0xE3, 0xD0, len(image) - 0xFF00), image[0xFF00:])
|
||||
cmd(struct.pack(">BB13x", 0xE8, 0x51))
|
||||
finally:
|
||||
os.close(fd)
|
||||
print("recovery flash done", flush=True)
|
||||
|
||||
|
||||
def vbus_write(value):
|
||||
try:
|
||||
with open(VBUS_PATH, "w") as f:
|
||||
f.write(value + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def vbus_cycle():
|
||||
if os.path.exists(VBUS_PATH):
|
||||
vbus_write("0")
|
||||
time.sleep(2)
|
||||
vbus_write("1")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def activate(expected_product):
|
||||
if not os.path.exists(VBUS_PATH):
|
||||
print("no VBUS control, firmware activates on the next dock power cycle", flush=True)
|
||||
return
|
||||
print("power-cycling the eGPU dock VBUS", flush=True)
|
||||
vbus_write("0")
|
||||
disconnected = False
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
disconnected = True
|
||||
break
|
||||
time.sleep(0.2)
|
||||
time.sleep(1)
|
||||
vbus_write("1")
|
||||
if not disconnected:
|
||||
print("dock stayed powered, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
deadline = time.monotonic() + 15.0
|
||||
while time.monotonic() < deadline:
|
||||
_, _, product = find_dock()
|
||||
if product is not None:
|
||||
if product == expected_product:
|
||||
print(f"activated {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"dock re-enumerated with {product!r}, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
print("dock did not re-enumerate, firmware activates on its next power cycle", flush=True)
|
||||
|
||||
|
||||
def defer_signal(signum, _frame):
|
||||
os.write(1, f"signal {signum} deferred until the dock is powered back up\n".encode())
|
||||
|
||||
|
||||
def flash_dock(expected_version=None, force=False):
|
||||
global _deadline
|
||||
|
||||
image = FIRMWARE_PATH.read_bytes()
|
||||
validate_image(image)
|
||||
expected_product = image_product(image)
|
||||
if expected_version is not None and expected_product != f"custom {expected_version}-CLEAN":
|
||||
raise RuntimeError(f"bundled firmware is {expected_product!r}, expected version {expected_version}")
|
||||
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock connected", flush=True)
|
||||
return
|
||||
if product == expected_product and not force:
|
||||
print(f"eGPU dock firmware is up to date ({expected_product})", flush=True)
|
||||
return
|
||||
|
||||
_deadline = time.monotonic() + FLASH_BUDGET
|
||||
for pm_path in PM_PATHS:
|
||||
disable_runtime_pm(pm_path)
|
||||
|
||||
previous = {sig: signal.signal(sig, defer_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
||||
try:
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
if not recover_from_rom(image, expected_product):
|
||||
return
|
||||
force, product = True, None
|
||||
write_image(image, expected_product, product, force)
|
||||
finally:
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
|
||||
def recover_from_rom(image, expected_product):
|
||||
backup = config_path()
|
||||
if not os.path.isfile(backup) and os.path.isfile(legacy_config_path()):
|
||||
backup = legacy_config_path()
|
||||
if not os.path.isfile(backup):
|
||||
raise RuntimeError(f"cannot recover from the ROM bootloader without a config backup at {config_path()}")
|
||||
with open(backup, "rb") as fh:
|
||||
config = fh.read()
|
||||
if len(config) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {backup}")
|
||||
|
||||
committed = False
|
||||
while True:
|
||||
check_budget()
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
if committed:
|
||||
print("dock is offline, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
vbus_cycle()
|
||||
continue
|
||||
if not in_rom_bootloader(vid_pid, product):
|
||||
return True
|
||||
if committed:
|
||||
print("dock stayed powered, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
try:
|
||||
rom_write(image, config)
|
||||
committed = True
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"ROM recovery failed, retrying: {e}", flush=True)
|
||||
vbus_cycle()
|
||||
continue
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def write_image(image, expected_product, product, force):
|
||||
if force:
|
||||
print(f"forced reflash of {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"eGPU dock firmware mismatch: {product!r}; expected {expected_product!r}", flush=True)
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
reconnect(flash)
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
config = saved_config(config_path(), config)
|
||||
image_end = IMAGE_OFFSET + len(image)
|
||||
first_sector = IMAGE_OFFSET & ~(SECTOR - 1)
|
||||
span = (image_end + SECTOR - 1) & ~(SECTOR - 1)
|
||||
current = stable_read(flash, first_sector, span - first_sector)
|
||||
target = bytearray(current)
|
||||
target[:len(config)] = config
|
||||
target[IMAGE_OFFSET - first_sector:image_end - first_sector] = image
|
||||
target = bytes(target)
|
||||
print(f"target {len(image)} bytes at 0x{IMAGE_OFFSET:05x}, sha256={hashlib.sha256(image).hexdigest()}", flush=True)
|
||||
|
||||
if not _still_offroad():
|
||||
raise RuntimeError("device went onroad before any sector was written; aborting flash")
|
||||
|
||||
for addr in range(first_sector, span, SECTOR):
|
||||
off = addr - first_sector
|
||||
wanted = target[off:off + SECTOR]
|
||||
if current[off:off + SECTOR] == wanted:
|
||||
print(f"sector 0x{addr:05x}: unchanged", flush=True)
|
||||
else:
|
||||
print(f"sector 0x{addr:05x}: programming", flush=True)
|
||||
program_sector(flash, addr, wanted)
|
||||
|
||||
verified = stable_read(flash, first_sector, span - first_sector, 3)
|
||||
if verified != target:
|
||||
raise RuntimeError("final full-image verification failed")
|
||||
print(f"verified sha256={hashlib.sha256(verified).hexdigest()}", flush=True)
|
||||
finally:
|
||||
flash.close()
|
||||
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def bundled_version() -> str:
|
||||
return image_product(FIRMWARE_PATH.read_bytes())
|
||||
|
||||
|
||||
def _still_offroad() -> bool:
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
return bool(Params().get_bool("IsOffroad"))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def dock_needs_flash(usb_devices: list[dict]) -> bool:
|
||||
try:
|
||||
expected = bundled_version()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
ids = tuple(tuple(int(x, 16) for x in p) for p in VID_PIDS + ROM_VID_PIDS)
|
||||
return any((d.get("vendorId"), d.get("productId")) in ids and d.get("product") != expected
|
||||
for d in usb_devices)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="check and flash the bundled eGPU dock firmware")
|
||||
parser.add_argument("version", nargs="?", help="expected firmware version hash")
|
||||
parser.add_argument("--force", action="store_true", help="reflash even when the version matches")
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError("flash.py must run as root")
|
||||
flash_dock(expected_version=args.version, force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import time
|
||||
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT, is_egpu_usb_device
|
||||
|
||||
EGPU_POWERED_VOLTAGE = 5000
|
||||
GPU_TEMP_LIMIT = 110.
|
||||
MEMORY_TEMP_LIMIT = 108.
|
||||
TEMP_HYSTERESIS = 5.
|
||||
FAN_START_GPU_TEMP = 60.
|
||||
FAN_STOP_GPU_TEMP = 50.
|
||||
FAN_START_MEMORY_TEMP = 70.
|
||||
FAN_STOP_MEMORY_TEMP = 60.
|
||||
FAN_STALLED_RPM = 250
|
||||
|
||||
|
||||
class EgpuDockStatus:
|
||||
def __init__(self):
|
||||
self.offroad = True
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.overheated = False
|
||||
self.fans_obstructed = False
|
||||
self.usb_seen = False
|
||||
self.usb_failed = False
|
||||
|
||||
def update(self, offroad, usb_state, firmware_failed, model_loading, model_active, compiled, state, set_alert):
|
||||
detected = [d for d in usb_state if is_egpu_usb_device(d["vendorId"], d["productId"], include_bootloader=True)]
|
||||
devices = [d for d in detected if is_egpu_usb_device(d["vendorId"], d["productId"])]
|
||||
firmware_ok = len(devices) == 1 and devices[0]["product"] == EGPU_DOCK_FW_PRODUCT
|
||||
|
||||
if self.offroad and not offroad:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.usb_seen = firmware_ok
|
||||
self.usb_failed = False
|
||||
|
||||
self.model_loading_seen |= model_loading
|
||||
self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None
|
||||
|
||||
if not offroad and self.usb_seen and not firmware_ok:
|
||||
self.usb_failed = True
|
||||
|
||||
if not offroad and self.model_attempted and state is not None:
|
||||
power_lost = state.supplyFault or state.supplyVoltage < EGPU_POWERED_VOLTAGE
|
||||
self.link_failures = self.link_failures + 1 if state.pcieLtssm != 0x78 else 0
|
||||
self.pcie_failed |= self.link_failures >= 2 or power_lost
|
||||
self.power_lost |= power_lost
|
||||
|
||||
if self.pcie_failed and self.power_lost and state is not None:
|
||||
self.power_restored |= not state.supplyFault and state.supplyVoltage >= EGPU_POWERED_VOLTAGE
|
||||
if self.usb_failed:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
|
||||
if state is not None:
|
||||
gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
memory_limit = MEMORY_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
self.overheated = state.tempC >= gpu_limit or state.memoryTempC >= memory_limit
|
||||
fan_hot = (state.tempC >= (FAN_STOP_GPU_TEMP if self.fans_obstructed else FAN_START_GPU_TEMP) or
|
||||
state.memoryTempC >= (FAN_STOP_MEMORY_TEMP if self.fans_obstructed else FAN_START_MEMORY_TEMP))
|
||||
self.fans_obstructed = fan_hot and state.fanSpeedRpm < FAN_STALLED_RPM
|
||||
|
||||
slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000
|
||||
set_alert("Offroad_EgpuNotDetected", self.usb_failed)
|
||||
set_alert("Offroad_EgpuFansObstructed", self.fans_obstructed)
|
||||
set_alert("Offroad_EgpuOverheated", self.overheated)
|
||||
set_alert("Offroad_EgpuUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None)
|
||||
if self.power_lost:
|
||||
pcie_action = "12V power was interrupted, possibly by engine start-stop. "
|
||||
pcie_action += ("Cycle ignition to reload the model." if self.power_restored else
|
||||
"Check 12V, then cycle ignition to reload the model.")
|
||||
else:
|
||||
pcie_action = "Check 12V connection."
|
||||
set_alert("Offroad_EgpuPcieUnavailable", self.pcie_failed, pcie_action)
|
||||
set_alert("Offroad_EgpuUncompiled", offroad and firmware_ok and not compiled)
|
||||
set_alert("Offroad_EgpuUpdateFailed", offroad and firmware_failed)
|
||||
self.offroad = offroad
|
||||
15
iqpilot/system/hardware/fan_controller.py
Executable file
15
iqpilot/system/hardware/fan_controller.py
Executable 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
|
||||
749
iqpilot/system/hardware/hardwared.py
Executable file
749
iqpilot/system/hardware/hardwared.py
Executable 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()
|
||||
137
iqpilot/system/hardware/hw.py
Normal file
137
iqpilot/system/hardware/hw.py
Normal 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"
|
||||
0
iqpilot/system/hardware/pc/__init__.py
Normal file
0
iqpilot/system/hardware/pc/__init__.py
Normal file
12
iqpilot/system/hardware/pc/hardware.py
Normal file
12
iqpilot/system/hardware/pc/hardware.py
Normal 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
|
||||
165
iqpilot/system/hardware/power_monitoring.py
Normal file
165
iqpilot/system/hardware/power_monitoring.py
Normal 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
|
||||
0
iqpilot/system/hardware/tests/__init__.py
Normal file
0
iqpilot/system/hardware/tests/__init__.py
Normal file
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
The eGPU dock flasher writes SPI flash, so the parts that decide WHETHER and
|
||||
WHAT to write are pinned here. The transfer path itself needs the hardware; the
|
||||
image validator, product parsing, config preservation and the needs-flash
|
||||
decision do not, and those are what stop a bad write.
|
||||
"""
|
||||
import os
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock import flash as f
|
||||
|
||||
|
||||
def _wrap(body: bytes, *, magic=0xA5, checksum=None, crc=None, body_len=None) -> bytes:
|
||||
n = len(body) if body_len is None else body_len
|
||||
cs = (sum(body) & 0xFF) if checksum is None else checksum
|
||||
c = zlib.crc32(body).to_bytes(4, "little") if crc is None else crc
|
||||
return n.to_bytes(4, "little") + body + bytes([magic, cs]) + c
|
||||
|
||||
|
||||
def test_bundled_firmware_is_valid_and_named():
|
||||
image = f.FIRMWARE_PATH.read_bytes()
|
||||
f.validate_image(image) # raises if the shipped blob is corrupt
|
||||
assert f.image_product(image).startswith("custom ")
|
||||
assert f.image_product(image).endswith("-CLEAN")
|
||||
assert f.bundled_version() == f.image_product(image)
|
||||
|
||||
|
||||
def test_validate_rejects_corruption():
|
||||
body = b"custom deadbeef-CLEAN" + bytes(64)
|
||||
f.validate_image(_wrap(body)) # the good case
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(b"\x00" * 4) # too short
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, magic=0x00)) # bad magic
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, checksum=0x00))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, crc=b"\x00\x00\x00\x00"))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, body_len=len(body) + 1)) # length disagrees
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image((f.MAX_CODE_SIZE + 1).to_bytes(4, "little") + bytes(16))
|
||||
|
||||
|
||||
def test_image_product_requires_a_version_string():
|
||||
with pytest.raises(ValueError):
|
||||
f.image_product(b"no version here")
|
||||
|
||||
|
||||
def test_saved_config_preserves_the_first_backup(tmp_path):
|
||||
# the config page is per-unit; a reflash must rewrite the ORIGINAL, never the
|
||||
# bytes read back from a half-written dock
|
||||
p = str(tmp_path / "dock.bin")
|
||||
original = bytes(range(256))
|
||||
assert f.saved_config(p, original) == original
|
||||
# later flash reads something different -> the stored original wins
|
||||
assert f.saved_config(p, bytes(256)) == original
|
||||
with open(p, "rb") as fh:
|
||||
assert fh.read() == original
|
||||
|
||||
|
||||
def test_saved_config_rejects_wrong_size_backup(tmp_path):
|
||||
p = str(tmp_path / "dock.bin")
|
||||
with open(p, "wb") as fh:
|
||||
fh.write(b"\x00" * 8)
|
||||
with pytest.raises(RuntimeError):
|
||||
f.saved_config(p, bytes(256))
|
||||
|
||||
|
||||
def test_needs_flash_only_for_a_dock_on_wrong_firmware():
|
||||
expected = f.bundled_version()
|
||||
vid, pid = (int(x, 16) for x in f.VID_PIDS[0])
|
||||
rom_vid, rom_pid = (int(x, 16) for x in f.ROM_VID_PIDS[0])
|
||||
|
||||
assert not f.dock_needs_flash([])
|
||||
assert not f.dock_needs_flash([{"vendorId": 0x1234, "productId": 0x5678, "product": "something else"}])
|
||||
assert not f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": expected}])
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
# a ROM-mode board always needs flashing
|
||||
assert f.dock_needs_flash([{"vendorId": rom_vid, "productId": rom_pid, "product": f.ROM_PRODUCT}])
|
||||
|
||||
|
||||
def test_both_shipped_ids_trigger_the_check():
|
||||
for pair in f.VID_PIDS:
|
||||
vid, pid = (int(x, 16) for x in pair)
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
|
||||
|
||||
def test_rom_detection():
|
||||
assert f.in_rom_bootloader(f.ROM_VID_PIDS[0], "anything")
|
||||
assert f.in_rom_bootloader(("add1", "0001"), f.ROM_PRODUCT)
|
||||
assert f.in_rom_bootloader(("add1", "0001"), "AS2462something")
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), f.bundled_version())
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), None)
|
||||
|
||||
|
||||
def test_config_paths_are_per_host_and_have_a_legacy_fallback():
|
||||
host = os.uname().nodename
|
||||
assert f.config_path().endswith(f"{host}.bin")
|
||||
assert f.config_path().startswith(f.CONFIG_DIR)
|
||||
# a dock flashed on this device by stock openpilot left its backup elsewhere
|
||||
assert f.legacy_config_path().startswith(f.LEGACY_CONFIG_DIR)
|
||||
assert f.legacy_config_path() != f.config_path()
|
||||
|
||||
|
||||
def test_we_do_not_autoflash():
|
||||
# upstream flashes from hardwared automatically; ours must stay deliberate
|
||||
# until it has been validated against a real dock
|
||||
import subprocess
|
||||
root = os.path.join(os.path.dirname(f.__file__), "..", "..", "..")
|
||||
hits = subprocess.run(["grep", "-rnI", "--exclude-dir=__pycache__", "flash_dock", os.path.join(root, "system"),
|
||||
os.path.join(root, "iqpilot")], capture_output=True, text=True).stdout
|
||||
callers = [ln for ln in hits.splitlines() if "egpu_dock/flash.py" not in ln and "test_" not in ln]
|
||||
assert callers == [], f"unexpected automatic flash caller: {callers}"
|
||||
|
||||
|
||||
def test_runtime_fw_gate_is_pinned_to_the_bundled_firmware():
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT
|
||||
assert EGPU_DOCK_FW_PRODUCT == f.bundled_version()
|
||||
|
||||
|
||||
def test_register_reads_default_to_superspeed_size():
|
||||
assert f.MAX_REGISTER_READ_SIZE == 255
|
||||
assert f.Flash().max_register_read_size == f.MAX_REGISTER_READ_SIZE
|
||||
|
||||
|
||||
def test_link_up_is_false_without_a_dock():
|
||||
assert f.link_up() is False
|
||||
41
iqpilot/system/hardware/tests/test_fan_controller.py
Normal file
41
iqpilot/system/hardware/tests/test_fan_controller.py
Normal 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
|
||||
75
iqpilot/system/hardware/tests/test_hardwared.py
Normal file
75
iqpilot/system/hardware/tests/test_hardwared.py
Normal 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)
|
||||
323
iqpilot/system/hardware/tests/test_power_monitoring.py
Normal file
323
iqpilot/system/hardware/tests/test_power_monitoring.py
Normal 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)
|
||||
279
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
279
iqpilot/system/hardware/tests/test_usb_state.py
Normal 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")
|
||||
0
iqpilot/system/hardware/tici/__init__.py
Normal file
0
iqpilot/system/hardware/tici/__init__.py
Normal file
91
iqpilot/system/hardware/tici/agnos.json
Normal file
91
iqpilot/system/hardware/tici/agnos.json
Normal 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
|
||||
}
|
||||
]
|
||||
1
iqpilot/system/hardware/tici/agnos.json.sig
Normal file
1
iqpilot/system/hardware/tici/agnos.json.sig
Normal file
@@ -0,0 +1 @@
|
||||
nqwhbcjeRqyPEM2UWshbP4eCC8EZzDAptGOG0refjvhHlEh32UCAp2Vi/GEKCGOLC3peRW8dRUgwCOXwtEm8Cg==
|
||||
433
iqpilot/system/hardware/tici/agnos.py
Executable file
433
iqpilot/system/hardware/tici/agnos.py
Executable 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)
|
||||
80
iqpilot/system/hardware/tici/agnos_tici_15_1.json
Normal file
80
iqpilot/system/hardware/tici/agnos_tici_15_1.json
Normal 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
|
||||
}
|
||||
]
|
||||
1
iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig
Normal file
1
iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig
Normal file
@@ -0,0 +1 @@
|
||||
JqXKQi3b6oUtR8CYSq6qoeGjE4SRViVSqYcL8dbMgwXGBkEY2fVCYDrO3nhzhEu7yv8EWGuvK4WiMdGB52iJAQ==
|
||||
400
iqpilot/system/hardware/tici/all-partitions.json
Normal file
400
iqpilot/system/hardware/tici/all-partitions.json
Normal 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"
|
||||
}
|
||||
]
|
||||
159
iqpilot/system/hardware/tici/amplifier.py
Normal file
159
iqpilot/system/hardware/tici/amplifier.py
Normal 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)
|
||||
30
iqpilot/system/hardware/tici/esim.nmconnection
Normal file
30
iqpilot/system/hardware/tici/esim.nmconnection
Normal 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]
|
||||
290
iqpilot/system/hardware/tici/esim_manager.py
Normal file
290
iqpilot/system/hardware/tici/esim_manager.py
Normal 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
|
||||
133
iqpilot/system/hardware/tici/gsma_ci_bundle.pem
Normal file
133
iqpilot/system/hardware/tici/gsma_ci_bundle.pem
Normal 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-----
|
||||
740
iqpilot/system/hardware/tici/hardware.py
Normal file
740
iqpilot/system/hardware/tici/hardware.py
Normal 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())
|
||||
28
iqpilot/system/hardware/tici/id_rsa
Normal file
28
iqpilot/system/hardware/tici/id_rsa
Normal 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-----
|
||||
35
iqpilot/system/hardware/tici/iwlist.py
Normal file
35
iqpilot/system/hardware/tici/iwlist.py
Normal 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
|
||||
1482
iqpilot/system/hardware/tici/lpa.py
Executable file
1482
iqpilot/system/hardware/tici/lpa.py
Executable file
File diff suppressed because it is too large
Load Diff
30
iqpilot/system/hardware/tici/pins.py
Normal file
30
iqpilot/system/hardware/tici/pins.py
Normal 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
|
||||
66
iqpilot/system/hardware/tici/power_monitor.py
Executable file
66
iqpilot/system/hardware/tici/power_monitor.py
Executable 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}")
|
||||
145
iqpilot/system/hardware/tici/qr_decode.py
Normal file
145
iqpilot/system/hardware/tici/qr_decode.py
Normal 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()
|
||||
18
iqpilot/system/hardware/tici/restart_modem.sh
Executable file
18
iqpilot/system/hardware/tici/restart_modem.sh
Executable 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
|
||||
233
iqpilot/system/hardware/tici/set_usb_storage.sh
Executable file
233
iqpilot/system/hardware/tici/set_usb_storage.sh
Executable 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
|
||||
0
iqpilot/system/hardware/tici/tests/__init__.py
Normal file
0
iqpilot/system/hardware/tici/tests/__init__.py
Normal file
73
iqpilot/system/hardware/tici/tests/compare_casync_manifest.py
Executable file
73
iqpilot/system/hardware/tici/tests/compare_casync_manifest.py
Executable 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}")
|
||||
37
iqpilot/system/hardware/tici/tests/test_agnos_updater.py
Normal file
37
iqpilot/system/hardware/tici/tests/test_agnos_updater.py
Normal 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"
|
||||
66
iqpilot/system/hardware/tici/tests/test_amplifier.py
Normal file
66
iqpilot/system/hardware/tici/tests/test_amplifier.py
Normal 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")
|
||||
238
iqpilot/system/hardware/tici/tests/test_esim.py
Normal file
238
iqpilot/system/hardware/tici/tests/test_esim.py
Normal 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"
|
||||
102
iqpilot/system/hardware/tici/tests/test_hardware.py
Normal file
102
iqpilot/system/hardware/tici/tests/test_hardware.py
Normal 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
|
||||
53
iqpilot/system/hardware/tici/tests/test_lpa_activation.py
Normal file
53
iqpilot/system/hardware/tici/tests/test_lpa_activation.py
Normal 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"]
|
||||
128
iqpilot/system/hardware/tici/tests/test_power_draw.py
Normal file
128
iqpilot/system/hardware/tici/tests/test_power_draw.py
Normal 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")
|
||||
17
iqpilot/system/hardware/tici/updater
Executable file
17
iqpilot/system/hardware/tici/updater
Executable 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
|
||||
BIN
iqpilot/system/hardware/tici/updater_magic
Executable file
BIN
iqpilot/system/hardware/tici/updater_magic
Executable file
Binary file not shown.
BIN
iqpilot/system/hardware/tici/updater_weston
Executable file
BIN
iqpilot/system/hardware/tici/updater_weston
Executable file
Binary file not shown.
71
iqpilot/system/hardware/tici/usb_storage.py
Normal file
71
iqpilot/system/hardware/tici/usb_storage.py
Normal 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
|
||||
19
iqpilot/system/hardware/tici/zram_setup.sh
Executable file
19
iqpilot/system/hardware/tici/zram_setup.sh
Executable 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"
|
||||
205
iqpilot/system/hardware/usb.py
Normal file
205
iqpilot/system/hardware/usb.py
Normal 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
|
||||
38
iqpilot/system/hephaestusd.service
Normal file
38
iqpilot/system/hephaestusd.service
Normal 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
|
||||
48
iqpilot/system/install_ble_transportd_service.sh
Normal file
48
iqpilot/system/install_ble_transportd_service.sh
Normal 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"
|
||||
47
iqpilot/system/install_flockd_service.sh
Normal file
47
iqpilot/system/install_flockd_service.sh
Normal 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"
|
||||
48
iqpilot/system/install_hephaestusd_service.sh
Executable file
48
iqpilot/system/install_hephaestusd_service.sh
Executable 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
43
iqpilot/system/journald.py
Executable 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
4
iqpilot/system/loggerd/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
loggerd
|
||||
encoderd
|
||||
bootlog
|
||||
tests/test_logger
|
||||
0
iqpilot/system/loggerd/__init__.py
Normal file
0
iqpilot/system/loggerd/__init__.py
Normal file
BIN
iqpilot/system/loggerd/bootlog
Executable file
BIN
iqpilot/system/loggerd/bootlog
Executable file
Binary file not shown.
31
iqpilot/system/loggerd/config.py
Normal file
31
iqpilot/system/loggerd/config.py
Normal 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
|
||||
45
iqpilot/system/loggerd/crash_recovery.py
Normal file
45
iqpilot/system/loggerd/crash_recovery.py
Normal 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
117
iqpilot/system/loggerd/deleter.py
Executable 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()
|
||||
BIN
iqpilot/system/loggerd/encoder/v4l_decode
Executable file
BIN
iqpilot/system/loggerd/encoder/v4l_decode
Executable file
Binary file not shown.
BIN
iqpilot/system/loggerd/encoderd
Executable file
BIN
iqpilot/system/loggerd/encoderd
Executable file
Binary file not shown.
BIN
iqpilot/system/loggerd/loggerd
Executable file
BIN
iqpilot/system/loggerd/loggerd
Executable file
Binary file not shown.
0
iqpilot/system/loggerd/tests/__init__.py
Normal file
0
iqpilot/system/loggerd/tests/__init__.py
Normal file
57
iqpilot/system/loggerd/tests/deleter_tests_common.py
Normal file
57
iqpilot/system/loggerd/tests/deleter_tests_common.py
Normal 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
|
||||
117
iqpilot/system/loggerd/tests/test_deleter.py
Normal file
117
iqpilot/system/loggerd/tests/test_deleter.py
Normal 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"
|
||||
152
iqpilot/system/loggerd/tests/test_encoder.py
Normal file
152
iqpilot/system/loggerd/tests/test_encoder.py
Normal 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()
|
||||
377
iqpilot/system/loggerd/tests/test_loggerd.py
Normal file
377
iqpilot/system/loggerd/tests/test_loggerd.py
Normal 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
|
||||
13
iqpilot/system/loggerd/tests/vidc_debug.sh
Executable file
13
iqpilot/system/loggerd/tests/vidc_debug.sh
Executable 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
|
||||
21
iqpilot/system/loggerd/uploader_common.py
Normal file
21
iqpilot/system/loggerd/uploader_common.py
Normal 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 []
|
||||
28
iqpilot/system/loggerd/xattr_cache.py
Normal file
28
iqpilot/system/loggerd/xattr_cache.py
Normal 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
58
iqpilot/system/logmessaged.py
Executable 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()
|
||||
0
iqpilot/system/manager/__init__.py
Normal file
0
iqpilot/system/manager/__init__.py
Normal file
198
iqpilot/system/manager/build.py
Executable file
198
iqpilot/system/manager/build.py
Executable 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)
|
||||
40
iqpilot/system/manager/github_runner.sh
Executable file
40
iqpilot/system/manager/github_runner.sh
Executable 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
|
||||
104
iqpilot/system/manager/helpers.py
Normal file
104
iqpilot/system/manager/helpers.py
Normal 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
309
iqpilot/system/manager/manager.py
Executable 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)
|
||||
365
iqpilot/system/manager/process.py
Normal file
365
iqpilot/system/manager/process.py
Normal 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
|
||||
258
iqpilot/system/manager/process_config.py
Normal file
258
iqpilot/system/manager/process_config.py
Normal 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
Reference in New Issue
Block a user