IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.system.hardware import PC
|
||||
|
||||
DEFAULT_DOWNLOAD_CACHE_ROOT = "/tmp/comma_download_cache"
|
||||
|
||||
class Paths:
|
||||
_persist_root_cache: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _is_writable_persist_root(path: str) -> bool:
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
comma_dir = os.path.join(path, "comma")
|
||||
os.makedirs(comma_dir, exist_ok=True)
|
||||
|
||||
probe_path = os.path.join(comma_dir, ".rw_probe")
|
||||
with open(probe_path, "w") as f:
|
||||
f.write("1")
|
||||
os.remove(probe_path)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def comma_home() -> str:
|
||||
return os.path.join(str(Path.home()), ".comma" + os.environ.get("OPENPILOT_PREFIX", ""))
|
||||
|
||||
@staticmethod
|
||||
def params() -> str:
|
||||
if os.environ.get("PARAMS_ROOT"):
|
||||
return os.environ["PARAMS_ROOT"]
|
||||
return os.path.join(Paths.comma_home(), "params") if PC else "/data/params"
|
||||
|
||||
@staticmethod
|
||||
def log_root() -> str:
|
||||
if os.environ.get('LOG_ROOT', False):
|
||||
return os.environ['LOG_ROOT']
|
||||
elif PC:
|
||||
return str(Path(Paths.comma_home()) / "media" / "0" / "realdata")
|
||||
else:
|
||||
return '/data/media/0/realdata/'
|
||||
|
||||
@staticmethod
|
||||
def log_root_external() -> str:
|
||||
return '/mnt/external_realdata/'
|
||||
|
||||
@staticmethod
|
||||
def swaglog_root() -> str:
|
||||
if PC:
|
||||
return os.path.join(Paths.comma_home(), "log")
|
||||
else:
|
||||
return "/data/log/"
|
||||
|
||||
@staticmethod
|
||||
def swaglog_ipc() -> str:
|
||||
return "ipc:///tmp/logmessage" + os.environ.get("OPENPILOT_PREFIX", "")
|
||||
|
||||
@staticmethod
|
||||
def download_cache_root() -> str:
|
||||
if os.environ.get('COMMA_CACHE', False):
|
||||
return os.environ['COMMA_CACHE'] + "/"
|
||||
return DEFAULT_DOWNLOAD_CACHE_ROOT + os.environ.get("OPENPILOT_PREFIX", "") + "/"
|
||||
|
||||
@staticmethod
|
||||
def persist_root() -> str:
|
||||
if PC:
|
||||
return os.path.join(Paths.comma_home(), "persist")
|
||||
|
||||
if Paths._persist_root_cache is not None:
|
||||
return Paths._persist_root_cache
|
||||
|
||||
for candidate in ("/persist", "/data/persist"):
|
||||
if Paths._is_writable_persist_root(candidate):
|
||||
Paths._persist_root_cache = candidate
|
||||
return candidate
|
||||
|
||||
# Keep previous behavior as a last resort.
|
||||
Paths._persist_root_cache = "/persist"
|
||||
return Paths._persist_root_cache
|
||||
|
||||
@staticmethod
|
||||
def stats_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "stats")
|
||||
else:
|
||||
return "/data/stats/"
|
||||
|
||||
@staticmethod
|
||||
def stats_iq_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "stats")
|
||||
else:
|
||||
return "/data/stats_iq/"
|
||||
|
||||
@staticmethod
|
||||
def config_root() -> str:
|
||||
if PC:
|
||||
return Paths.comma_home()
|
||||
else:
|
||||
return "/tmp/.comma"
|
||||
|
||||
@staticmethod
|
||||
def shm_path() -> str:
|
||||
if PC and platform.system() == "Darwin":
|
||||
return "/tmp" # This is not really shared memory on macOS, but it's the closest we can get
|
||||
return "/dev/shm"
|
||||
|
||||
@staticmethod
|
||||
def model_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "media" / "0" / "models")
|
||||
else:
|
||||
return "/data/media/0/models"
|
||||
|
||||
@staticmethod
|
||||
def crash_log_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "community" / "crashes")
|
||||
else:
|
||||
return "/data/community/crashes"
|
||||
|
||||
@staticmethod
|
||||
def mapd_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "media" / "0" / "osm")
|
||||
else:
|
||||
return "/data/media/0/osm"
|
||||
|
||||
@staticmethod
|
||||
def screen_recordings_root() -> str:
|
||||
if PC:
|
||||
return str(Path(Paths.comma_home()) / "media" / "0" / "screen_recordings")
|
||||
else:
|
||||
return "/data/media/0/screen_recordings"
|
||||
@@ -0,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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,35 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def scan(interface="wlan0"):
|
||||
result = []
|
||||
try:
|
||||
r = subprocess.check_output(["iwlist", interface, "scan"], encoding='utf8')
|
||||
|
||||
mac = None
|
||||
for line in r.split('\n'):
|
||||
if "Address" in line:
|
||||
# Based on the adapter eithere a percentage or dBm is returned
|
||||
# Add previous network in case no dBm signal level was seen
|
||||
if mac is not None:
|
||||
result.append({"mac": mac})
|
||||
mac = None
|
||||
|
||||
mac = line.split(' ')[-1]
|
||||
elif "dBm" in line:
|
||||
try:
|
||||
level = line.split('Signal level=')[1]
|
||||
rss = int(level.split(' ')[0])
|
||||
result.append({"mac": mac, "rss": rss})
|
||||
mac = None
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Add last network if no dBm was found
|
||||
if mac is not None:
|
||||
result.append({"mac": mac})
|
||||
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
Reference in New Issue
Block a user