IQ.Pilot Prebuilt Release @ 67fd9c2

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-01 20:15:16 -05:00
commit 13523543ee
2549 changed files with 678222 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -0,0 +1,184 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
USB bus snapshot for deviceState: every enumerated device with its negotiated
speed and its controller's link-error count. Landing this in every rlog makes
cable/hub/link regressions diagnosable from a recorded route instead of only
live.
Link errors come from `portli` on the ssusb controller (IQ.OS 4.9.1+); on older
builds the file is absent and the counts read 0.
The USB eGPU dock is identified by VID/PID only. comma's internal codename for
it is deliberately not used here: IQ.Pilot runs these models on several
backends (eGPU dock, eMac), so the naming stays about the role, not the vendor.
"""
from pathlib import Path
# comma's USB eGPU dock, both shipped USB IDs. The ROM ids are the same board
# sitting in its bootloader (ASMedia) before vendor firmware is flashed — it
# enumerates but cannot serve a GPU in that state.
EGPU_DOCK_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
EGPU_DOCK_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
# must equal image_product() of the bundled firmware; test_egpu_dock_flash pins them together
EGPU_DOCK_FW_PRODUCT = "custom ed4e39b7-CLEAN"
def is_egpu_usb_device(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool:
ids = EGPU_DOCK_USB_IDS + EGPU_DOCK_ROM_USB_IDS if include_bootloader else EGPU_DOCK_USB_IDS
return (vendor_id, product_id) in ids
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
UDC_PATH = Path("/sys/class/udc")
TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation")
USB3_LANES = {1: "a", 2: "b"} # 0 = unattached
SOC_PLATFORM_PATH = Path("/sys/devices/platform/soc")
CONTROLLER_SUFFIX = ".ssusb"
LINK_ERRORS_FILE = "portli"
def read(path: Path) -> str | None:
# a controller in peripheral mode fails portli's show(); that surfaces as TypeError, not OSError
try:
return path.read_text().strip()
except Exception:
return None
def read_int(path: Path, base: int = 10) -> int:
try:
return int(path.read_text(), base)
except Exception:
return 0
def read_hex_counter(path: Path) -> int:
"""sysfs counter printed as '0x0000002a' (portli), tolerating a bare hex value."""
raw = read(path)
if raw is None:
return 0
try:
return int(raw, 0) if raw.lower().startswith("0x") else int(raw, 16)
except ValueError:
return 0
def get_usb_topology(root: Path = USB_DEVICES_PATH) -> set[str]:
"""Names of everything on the bus; a cheap way to detect hotplug without
re-reading every attribute."""
try:
return {p.name for p in root.iterdir()}
except Exception:
return set()
def usb_devices(root: Path = USB_DEVICES_PATH) -> list[Path]:
try:
return sorted((d for d in root.glob("*") if (d / "idVendor").exists()), key=lambda p: p.name)
except Exception:
return []
def controller(device: Path) -> Path | None:
"""The SuperSpeed controller a device hangs off (…/a800000.ssusb)."""
try:
return next((p for p in device.resolve().parents if p.name.endswith(CONTROLLER_SUFFIX)), None)
except Exception:
return None
def usb_controllers(soc: Path = SOC_PLATFORM_PATH) -> list[Path]:
try:
return sorted(soc.glob(f"*{CONTROLLER_SUFFIX}"))
except Exception:
return []
def link_controller(udc_root: Path = UDC_PATH) -> str:
"""Name of the Type-C port's controller, derived from the UDC rather than
hardcoded: the gadget exposes `<addr>.dwc3`, whose address prefix is the
`<addr>.ssusb` controller behind the same connector. comma pins the 3X value
directly, which would be wrong on any other board."""
try:
udc = next(iter(sorted(p.name for p in udc_root.iterdir())), "")
except Exception:
return ""
return f"{udc.split('.')[0]}{CONTROLLER_SUFFIX}" if udc else ""
def usb3_lane(orientation: int | None = None) -> str:
"""Which SuperSpeed lane the Type-C connector landed on. Unattached reads 0,
which is 'unknown' rather than a lane."""
if orientation is None:
orientation = read_int(TYPEC_CC_ORIENTATION_PATH)
return USB3_LANES.get(orientation, "unknown")
def link_errors(ctrl: Path | None) -> int:
return read_hex_counter(ctrl / LINK_ERRORS_FILE) if ctrl is not None else 0
def get_link_error_count(soc: Path = SOC_PLATFORM_PATH) -> int:
"""Cumulative SS port link errors, read off the controller rather than a
device: in peripheral mode (eMac gadget link) the peer never enumerates on
our side, so there is no device row to carry the count."""
return sum(link_errors(c) for c in usb_controllers(soc))
def egpu_dock_present(root: Path = USB_DEVICES_PATH) -> bool:
"""A dock in ROM/bootloader state is deliberately NOT counted as present: it
enumerates but cannot serve a GPU until vendor firmware is flashed."""
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
for d in usb_devices(root))
def egpu_dock_ready(root: Path = USB_DEVICES_PATH) -> bool:
"""Present AND running the exact firmware we ship. A dock on any other
firmware enumerates fine but has not been validated with this stack, so the
runtime refuses it; the flasher still sees it via egpu_dock_present."""
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
and (read(d / "product") or "").strip() == EGPU_DOCK_FW_PRODUCT
for d in usb_devices(root))
def get_usb_state(root: Path = USB_DEVICES_PATH, udc_root: Path = UDC_PATH) -> list[dict]:
devices = []
lane, link_ctrl = usb3_lane(), link_controller(udc_root)
for device in usb_devices(root):
ctrl = controller(device)
devices.append({
"usb3Lane": lane if ctrl is not None and ctrl.name == link_ctrl else "unknown",
"busnum": read_int(device / "busnum"),
"devnum": read_int(device / "devnum"),
"vendorId": read_int(device / "idVendor", 16),
"productId": read_int(device / "idProduct", 16),
"speedMbps": read_int(device / "speed"),
"manufacturer": read(device / "manufacturer") or "",
"product": read(device / "product") or "",
# 16-bit field upstream, so mask rather than let a wrapped counter overflow it
"linkErrorCount": link_errors(ctrl) & 0xFFFF,
})
return devices
def set_usb_state(device_state, devices: list[dict], link_error_count: int = 0,
lane: str | None = None) -> None:
entries = device_state.usbState.init('devices', len(devices))
dock_present = False
for entry, device in zip(entries, devices, strict=True):
entry.busnum = device["busnum"]
entry.devnum = device["devnum"]
entry.vendorId = device["vendorId"]
entry.productId = device["productId"]
entry.speedMbps = device["speedMbps"]
entry.manufacturer = device["manufacturer"]
entry.product = device["product"]
entry.linkErrorCount = device.get("linkErrorCount", 0) & 0xFFFF
entry.usb3Lane = device.get("usb3Lane", "unknown")
if (entry.vendorId, entry.productId) in EGPU_DOCK_USB_IDS:
dock_present = True
device_state.usbState.linkErrorCount = link_error_count
device_state.usbState.usb3Lane = lane if lane is not None else usb3_lane()
device_state.egpuDockPresent = dock_present

View File

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

View File

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

View File

@@ -0,0 +1,254 @@
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 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("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),
]
managed_processes = {p.name: p for p in procs}

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import numpy as np
import os
import time
from functools import cache
import threading
from iqpilot.cereal import messaging
from iqpilot.common.params import Params
from iqpilot.common.realtime import Ratekeeper
from iqpilot.common.utils import retry
from iqpilot.common.swaglog import cloudlog
RATE = 10
FFT_SAMPLES = 1600 # 100ms
REFERENCE_SPL = 2e-5 # newtons/m^2
SAMPLE_RATE = 16000
SAMPLE_BUFFER = 800 # 50ms
@cache
def get_a_weighting_filter():
# Calculate the A-weighting filter
# https://en.wikipedia.org/wiki/A-weighting
freqs = np.fft.fftfreq(FFT_SAMPLES, d=1 / SAMPLE_RATE)
A = 12194 ** 2 * freqs ** 4 / ((freqs ** 2 + 20.6 ** 2) * (freqs ** 2 + 12194 ** 2) * np.sqrt((freqs ** 2 + 107.7 ** 2) * (freqs ** 2 + 737.9 ** 2)))
return A / np.max(A)
def calculate_spl(measurements):
# https://www.engineeringtoolbox.com/sound-pressure-d_711.html
sound_pressure = np.sqrt(np.mean(measurements ** 2)) # RMS of amplitudes
if sound_pressure > 0:
sound_pressure_level = 20 * np.log10(sound_pressure / REFERENCE_SPL) # dB
else:
sound_pressure_level = 0
return sound_pressure, sound_pressure_level
def apply_a_weighting(measurements: np.ndarray) -> np.ndarray:
# Generate a Hanning window of the same length as the audio measurements
measurements_windowed = measurements * np.hanning(len(measurements))
# Apply the A-weighting filter to the signal
return np.abs(np.fft.ifft(np.fft.fft(measurements_windowed) * get_a_weighting_filter()))
class Mic:
def __init__(self):
self.rk = Ratekeeper(RATE)
self.pm = messaging.PubMaster(['soundPressure', 'rawAudioData'])
self.params = Params()
self.measurements = np.empty(0)
self.sound_pressure = 0
self.sound_pressure_weighted = 0
self.sound_pressure_level_weighted = 0
self.lock = threading.Lock()
self.callback_count = 0
self.last_audio_rms = 0.0
self.last_audio_peak = 0.0
self.last_device = None
self.last_status = None
def update(self):
with self.lock:
sound_pressure = self.sound_pressure
sound_pressure_weighted = self.sound_pressure_weighted
sound_pressure_level_weighted = self.sound_pressure_level_weighted
callback_count = self.callback_count
audio_rms = self.last_audio_rms
audio_peak = self.last_audio_peak
device_name = self.last_device
status = self.last_status
msg = messaging.new_message('soundPressure', valid=True)
msg.soundPressure.soundPressure = float(sound_pressure)
msg.soundPressure.soundPressureWeighted = float(sound_pressure_weighted)
msg.soundPressure.soundPressureWeightedDb = float(sound_pressure_level_weighted)
self.pm.send('soundPressure', msg)
if callback_count % RATE == 0:
cloudlog.info(
f"micd health: callbacks={callback_count} rms={audio_rms:.6f} peak={audio_peak:.6f} "
f"device={device_name} status={status!r} livestream={self.params.get_bool('IsLiveStreaming')}"
)
self.rk.keep_time()
def callback(self, indata, frames, time, status):
"""
Using amplitude measurements, calculate an uncalibrated sound pressure and sound pressure level.
Then apply A-weighting to the raw amplitudes and run the same calculations again.
Logged A-weighted equivalents are rough approximations of the human-perceived loudness.
"""
msg = messaging.new_message('rawAudioData', valid=True)
audio_data_int_16 = (indata[:, 0] * 32767).astype(np.int16)
msg.rawAudioData.data = audio_data_int_16.tobytes()
msg.rawAudioData.sampleRate = SAMPLE_RATE
self.pm.send('rawAudioData', msg)
with self.lock:
self.callback_count += 1
self.last_audio_rms = float(np.sqrt(np.mean(np.square(indata[:, 0]))))
self.last_audio_peak = float(np.max(np.abs(indata[:, 0])))
self.last_status = str(status) if status else None
self.measurements = np.concatenate((self.measurements, indata[:, 0]))
while self.measurements.size >= FFT_SAMPLES:
measurements = self.measurements[:FFT_SAMPLES]
self.sound_pressure, _ = calculate_spl(measurements)
measurements_weighted = apply_a_weighting(measurements)
self.sound_pressure_weighted, self.sound_pressure_level_weighted = calculate_spl(measurements_weighted)
self.measurements = self.measurements[FFT_SAMPLES:]
@retry(attempts=10, delay=3)
def get_stream(self, sd):
# reload sounddevice to reinitialize portaudio
sd._terminate()
sd._initialize()
requested_device = os.environ.get("MICD_DEVICE")
device = int(requested_device) if requested_device is not None else None
return sd.InputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER, device=device)
def micd_thread(self):
# sounddevice must be imported after forking processes
import sounddevice as sd
device = sd.default.device
if os.environ.get("MICD_DEVICE") is not None:
device = int(os.environ["MICD_DEVICE"])
sd.default.device = (device, device)
self.last_device = f"{device}: {sd.query_devices(device)['name']}" if isinstance(device, int) else str(device)
cloudlog.info(f"micd selecting input device {self.last_device}")
while True:
try:
with self.get_stream(sd) as stream:
cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
while True:
self.update()
except Exception:
# Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here
# crash-loops the process and selfdrived raises a takeover alert mid-drive over a
# microphone - stay alive and keep retrying instead; recovers if the DSP comes back.
cloudlog.exception("micd: audio stream unavailable, retrying")
time.sleep(10)
def main():
mic = Mic()
mic.micd_thread()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
"""Install exception handler for process crash."""
import os
import shutil
import traceback
from datetime import datetime
import sentry_sdk
from enum import Enum
from sentry_sdk.integrations.threading import ThreadingIntegration
from iqpilot.common.params import Params
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.hardware.hw import Paths
from iqpilot.common.swaglog import cloudlog
from iqpilot.system.version import get_build_metadata, get_version
CRASHES_DIR = Paths.crash_log_root()
CRASH_UPLOADS_DIR = os.path.join(Paths.log_root(), "crash")
class SentryProject(Enum):
# python project
SELFDRIVE = "https://186a6736b7927e5ae9b92c869ba81b6b@o1138119.ingest.us.sentry.io/4508660076052480"
# native project
SELFDRIVE_NATIVE = SELFDRIVE
def _sentry_enabled() -> bool:
return os.getenv("IQPILOT_ENABLE_SENTRY", "0") == "1"
def _ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def _queue_crash_upload(src: str, name: str | None = None) -> None:
try:
_ensure_dir(CRASH_UPLOADS_DIR)
dest_name = name or os.path.basename(src)
shutil.copyfile(src, os.path.join(CRASH_UPLOADS_DIR, dest_name))
except Exception:
cloudlog.exception("error when attempting to queue crash upload")
def report_tombstone(fn: str, message: str, contents: str) -> None:
cloudlog.error({'tombstone': message})
if not _sentry_enabled():
return
with sentry_sdk.configure_scope() as scope:
set_user()
scope.set_extra("tombstone_fn", fn)
scope.set_extra("tombstone", contents)
sentry_sdk.capture_message(message=message)
sentry_sdk.flush()
def capture_exception(*args, **kwargs) -> None:
cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1))
try:
save_exception(traceback.format_exc())
if not _sentry_enabled():
return
set_user()
sentry_sdk.capture_exception(*args, **kwargs)
sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291
except Exception:
cloudlog.exception("sentry exception")
def save_exception(content: str) -> None:
try:
_ensure_dir(CRASHES_DIR)
commit = (get_build_metadata().openpilot.git_commit or "nocommit")[:8]
dated_fn = os.path.join(CRASHES_DIR, datetime.now().strftime("%Y-%m-%d--%H-%M-%S.log"))
files = [
dated_fn,
os.path.join(CRASHES_DIR, "error.log")
]
for fn in files:
with open(fn, 'w') as f:
if os.path.basename(fn) == "error.log":
lines = content.splitlines()[-3:]
f.write("\n".join(lines))
else:
f.write(content)
upload_name = f"{os.path.splitext(os.path.basename(dated_fn))[0]}_{commit}_python.log"
_queue_crash_upload(dated_fn, upload_name)
cloudlog.error(f"logged crash to {files}")
except Exception:
cloudlog.exception("error when attempting to save exception")
def capture_fingerprint_mock() -> None:
try:
set_user()
message = "car doesn't match any fingerprints"
sentry_sdk.capture_message(message=message, level="error")
sentry_sdk.flush()
except Exception as e:
cloudlog.exception(f"sentry fingerprint MOCK exception: {e}")
def capture_fingerprint(candidate: str, car_name: str) -> None:
try:
set_user()
sentry_sdk.set_tag("carFingerprint", candidate)
sentry_sdk.set_tag("carName", car_name)
message = f"Fingerprinted {candidate}"
sentry_sdk.capture_message(message=message, level="info")
sentry_sdk.flush()
except Exception as e:
cloudlog.exception(f"sentry fingerprint exception: {e}")
def set_tag(key: str, value: str) -> None:
sentry_sdk.set_tag(key, value)
def set_user() -> None:
dongle_id, git_username = get_properties()
sentry_sdk.set_user({"id": dongle_id, "name": git_username})
def get_properties() -> tuple[str, str]:
params = Params()
hardware_serial: str = params.get("HardwareSerial") or ""
git_username: str = params.get("GithubUsername") or ""
dongle_id: str = params.get("DongleId") or f"{UNREGISTERED_DONGLE_ID}-{hardware_serial}"
return dongle_id, git_username
def init(project: SentryProject) -> bool:
if not _sentry_enabled():
cloudlog.info("Sentry disabled, using local crash logging + konn3kt uploader")
return False
build_metadata = get_build_metadata()
env = build_metadata.channel_type
dongle_id, git_username = get_properties()
integrations = []
if project == SentryProject.SELFDRIVE:
integrations.append(ThreadingIntegration(propagate_hub=True))
sentry_sdk.init(project.value,
default_integrations=False,
release=get_version(),
integrations=integrations,
traces_sample_rate=1.0,
max_value_length=8192,
environment=env)
sentry_sdk.set_user({"id": dongle_id, "name": git_username})
sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty)
sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin)
sentry_sdk.set_tag("branch", build_metadata.channel)
sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit)
sentry_sdk.set_tag("device", HARDWARE.get_device_type())
return True

View File

@@ -0,0 +1,47 @@
from enum import IntEnum
# NetworkManager device states
class NMDeviceState(IntEnum):
UNKNOWN = 0
DISCONNECTED = 30
PREPARE = 40
STATE_CONFIG = 50
NEED_AUTH = 60
IP_CONFIG = 70
ACTIVATED = 100
DEACTIVATING = 110
# NetworkManager constants
NM = "org.freedesktop.NetworkManager"
NM_PATH = '/org/freedesktop/NetworkManager'
NM_IFACE = 'org.freedesktop.NetworkManager'
NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint'
NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings'
NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings'
NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection'
NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active'
NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless'
NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device'
NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
NM_802_11_AP_FLAGS_NONE = 0x0
NM_802_11_AP_FLAGS_PRIVACY = 0x1
NM_802_11_AP_FLAGS_WPS = 0x2
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags
NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001
NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002
NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010
NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020
NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100
NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200
NM_802_11_AP_SEC_KEY_MGMT_SAE = 0x00000400 # WPA3-Personal (SAE)

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
IQ.OS compatibility check + in-place AGNOS update for the setup flow.
A chosen IQ.Pilot channel may target a newer IQ.OS than the device is running
(its cloned tree pins the required version in launch_env.sh AGNOS_VERSION). When
that differs from the running /VERSION, the setup flow flashes the target IQ.OS
via comma's own agnos.py BEFORE writing continue.sh, so the single reboot lands
on a compatible OS. The risky flashing is delegated entirely to agnos.py; this
module only reads versions, picks the right manifest, and streams coarse
progress.
"""
import json
import os
import re
import subprocess
import threading
from typing import Callable
VERSION_PATH = "/VERSION"
def current_os_version() -> str:
try:
with open(VERSION_PATH) as f:
return f.read().strip()
except Exception:
return ""
def required_agnos_version(install_path: str) -> str:
"""Read the target OS version the cloned fork pins in launch_env.sh."""
path = os.path.join(install_path, "launch_env.sh")
try:
with open(path) as f:
for line in f:
m = re.search(r'AGNOS_VERSION\s*=\s*"([^"]+)"', line)
if m:
return m.group(1).strip()
except Exception:
pass
return ""
def _hardware_dir(install_path: str) -> str:
nested = os.path.join(install_path, "iqpilot", "system", "hardware", "tici")
if os.path.isdir(nested):
return nested
return os.path.join(install_path, "system", "hardware", "tici")
def agnos_manifest_path(install_path: str, device_type: str) -> str:
# comma 3 (tici) uses a different AGNOS manifest than comma 3x (tizi) / comma 4 (mici).
fname = "agnos_tici_15_1.json" if device_type == "tici" else "agnos.json"
return os.path.join(_hardware_dir(install_path), fname)
def os_update_needed(install_path: str) -> tuple[bool, str, str]:
"""Returns (needed, current, required)."""
current = current_os_version()
required = required_agnos_version(install_path)
needed = bool(required and current and required != current)
return needed, current, required
ProgressCb = Callable[[int, str], None]
def run_agnos_update(install_path: str, device_type: str, progress_cb: ProgressCb) -> bool:
"""Flash + swap to the target IQ.OS. Streams coarse partition-level progress
via progress_cb(percent, note). Returns True on success. The device must be
rebooted by the caller afterward for the new slot to take effect."""
manifest = agnos_manifest_path(install_path, device_type)
agnos_py = os.path.join(_hardware_dir(install_path), "agnos.py")
if not os.path.isfile(manifest) or not os.path.isfile(agnos_py):
progress_cb(0, "manifest_missing")
return False
try:
total_partitions = max(1, len(json.load(open(manifest))))
except Exception:
total_partitions = 1
progress_cb(1, "starting")
try:
proc = subprocess.Popen(
["python3", agnos_py, "--swap", manifest],
cwd=install_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env={**os.environ, "PYTHONPATH": install_path},
)
except Exception:
progress_cb(0, "launch_failed")
return False
completed = 0
swapping = False
assert proc.stdout is not None
for line in proc.stdout:
line = line.strip()
if "Downloading and writing" in line or "Already flashed" in line:
completed += 1
pct = min(94, int((completed / total_partitions) * 90) + 2)
progress_cb(pct, "flashing")
elif "Swapping to slot" in line or "AGNOS ready" in line:
swapping = True
progress_cb(96, "swapping")
proc.wait()
if proc.returncode == 0:
progress_cb(100, "done")
return True
progress_cb(0, "failed" if not swapping else "swap_failed")
return False
class OsUpdateCoordinator:
"""Bridges the setup UI's install thread and the BLE confirmation from the app.
The install thread posts a required-update, waits for the phone's confirm, then
runs the flash. On-screen setup can confirm locally too."""
def __init__(self):
self.confirmed = threading.Event()
self.needed = False
self.current = ""
self.required = ""
def request(self, current: str, required: str) -> None:
self.needed = True
self.current = current
self.required = required
self.confirmed.clear()
def confirm(self) -> None:
self.confirmed.set()
def wait_for_confirm(self, timeout: float) -> bool:
return self.confirmed.wait(timeout=timeout)

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
from dataclasses import dataclass
from functools import cache
import json
import os
import pathlib
import subprocess
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.swaglog import cloudlog
from iqpilot.common.git import get_commit, get_origin, get_branch, get_short_branch, get_commit_date
RELEASE_IQ_BRANCHES = ['release', 'release-tici', 'release-new']
TESTED_BRANCHES = RELEASE_IQ_BRANCHES
IQ_BRANCH_MIGRATIONS: dict[tuple[str, str], str] = {}
BUILD_METADATA_FILENAME = "build.json"
training_version: str = "0.2.0"
terms_version: str = "2"
UNKNOWN_VERSION = "0.0.0"
@cache
def get_version(path: str = BASEDIR) -> str:
try:
with open(os.path.join(path, "iqpilot", "common", "version.h")) as _versionf:
return _versionf.read().split('"')[1]
except (OSError, IndexError):
return UNKNOWN_VERSION
def get_release_notes(path: str = BASEDIR) -> str:
for rel in (("iqpilot", "docs", "CHANGELOG.md"), ("docs", "CHANGELOG.md")):
try:
with open(os.path.join(path, *rel)) as f:
return f.read().split('\n\n', 1)[0]
except OSError:
continue
return ""
@cache
def is_prebuilt(path: str = BASEDIR) -> bool:
return os.path.exists(os.path.join(path, 'prebuilt'))
@cache
def is_dirty(cwd: str = BASEDIR) -> bool:
if not get_origin() or not get_short_branch():
return True
dirty = False
try:
# Actually check dirty files
if not is_prebuilt(cwd):
# This is needed otherwise touched files might show up as modified
try:
subprocess.check_call(["git", "update-index", "--refresh"], cwd=cwd)
except subprocess.CalledProcessError:
pass
branch = get_branch()
if not branch:
return True
dirty = (subprocess.call(["git", "diff-index", "--quiet", branch, "--"], cwd=cwd)) != 0
except subprocess.CalledProcessError:
cloudlog.exception("git subprocess failed while checking dirty")
dirty = True
return dirty
@dataclass
class OpenpilotMetadata:
version: str
release_notes: str
git_commit: str
git_origin: str
git_commit_date: str
build_style: str
is_dirty: bool # whether there are local changes
@property
def short_version(self) -> str:
return self.version.split('-')[0]
@property
def comma_remote(self) -> bool:
# note to fork maintainers, this is used for release metrics. please do not
# touch this to get rid of the orange startup alert. there's better ways to do that
return self.git_normalized_origin == "github.com/commaai/openpilot"
@property
def iqpilot_remote(self) -> bool:
return self.git_normalized_origin in ("github.com/iqpilot/iqpilot",
"github.com/iqpilot/openpilot")
@property
def git_normalized_origin(self) -> str:
return self.git_origin \
.replace("git@", "", 1) \
.replace(".git", "", 1) \
.replace("https://", "", 1) \
.replace(":", "/", 1)
@dataclass
class BuildMetadata:
channel: str
openpilot: OpenpilotMetadata
@property
def tested_channel(self) -> bool:
return self.channel in TESTED_BRANCHES
@property
def release_channel(self) -> bool:
return self.channel in RELEASE_IQ_BRANCHES
@property
def canonical(self) -> str:
return f"{self.openpilot.version}-{self.openpilot.git_commit}-{self.openpilot.build_style}"
@property
def ui_description(self) -> str:
return f"{self.openpilot.version} / {self.openpilot.git_commit[:6]} / {self.channel}"
@property
def master_channel(self) -> bool:
return self.channel in RELEASE_IQ_BRANCHES
@property
def development_channel(self) -> bool:
return self.channel == "dev" or self.channel.startswith("dev-") or self.channel.endswith("-prebuilt")
@property
def channel_type(self) -> str:
if "-tici" in self.channel or self.channel in ("release-new" or "master-mici"):
return "tici"
elif self.development_channel:
return "development"
elif self.tested_channel:
return "staging"
elif self.master_channel:
return "master"
elif self.release_channel:
return "release"
else:
return "feature"
def build_metadata_from_dict(build_metadata: dict) -> BuildMetadata:
channel = build_metadata.get("channel", "unknown")
openpilot_metadata = build_metadata.get("openpilot", {})
version = openpilot_metadata.get("version", "unknown")
release_notes = openpilot_metadata.get("release_notes", "unknown")
git_commit = openpilot_metadata.get("git_commit", "unknown")
git_origin = openpilot_metadata.get("git_origin", "unknown")
git_commit_date = openpilot_metadata.get("git_commit_date", "unknown")
build_style = openpilot_metadata.get("build_style", "unknown")
return BuildMetadata(channel,
OpenpilotMetadata(
version=version,
release_notes=release_notes,
git_commit=git_commit,
git_origin=git_origin,
git_commit_date=git_commit_date,
build_style=build_style,
is_dirty=False))
def get_build_metadata(path: str = BASEDIR) -> BuildMetadata:
build_metadata_path = pathlib.Path(path) / BUILD_METADATA_FILENAME
if build_metadata_path.exists():
build_metadata = json.loads(build_metadata_path.read_text())
return build_metadata_from_dict(build_metadata)
git_folder = pathlib.Path(path) / ".git"
if git_folder.exists():
return BuildMetadata(get_short_branch(path),
OpenpilotMetadata(
version=get_version(path),
release_notes=get_release_notes(path),
git_commit=get_commit(path),
git_origin=get_origin(path),
git_commit_date=get_commit_date(path),
build_style="unknown",
is_dirty=is_dirty(path)))
cloudlog.exception("unable to get build metadata")
raise Exception("invalid build metadata")
if __name__ == "__main__":
print(get_build_metadata())