diff --git a/system/hardware/.gitignore b/system/hardware/.gitignore new file mode 100644 index 0000000..980f09a --- /dev/null +++ b/system/hardware/.gitignore @@ -0,0 +1 @@ +eon/rat diff --git a/system/hardware/base.h b/system/hardware/base.h new file mode 100644 index 0000000..6fe65f8 --- /dev/null +++ b/system/hardware/base.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +#include "cereal/messaging/messaging.h" + +// no-op base hw class +class HardwareNone { +public: + static constexpr float MAX_VOLUME = 0.7; + static constexpr float MIN_VOLUME = 0.2; + + static std::string get_os_version() { return ""; } + static std::string get_name() { return ""; } + static cereal::InitData::DeviceType get_device_type() { return cereal::InitData::DeviceType::UNKNOWN; } + static int get_voltage() { return 0; } + static int get_current() { return 0; } + + static std::string get_serial() { return "cccccc"; } + + static std::map get_init_logs() { + return {}; + } + + static void reboot() {} + static void soft_reboot() {} + static void poweroff() {} + static void set_brightness(int percent) {} + static void set_display_power(bool on) {} + + static bool get_ssh_enabled() { return false; } + static void set_ssh_enabled(bool enabled) {} + + static void config_cpu_rendering(bool offscreen); + + static bool PC() { return false; } + static bool TICI() { return false; } + static bool AGNOS() { return false; } +}; diff --git a/system/hardware/base.py b/system/hardware/base.py index 7434bb6..6673f84 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -1,6 +1,5 @@ from abc import abstractmethod, ABC from collections import namedtuple -from typing import Dict from cereal import log @@ -10,7 +9,7 @@ NetworkType = log.DeviceState.NetworkType class HardwareBase(ABC): @staticmethod - def get_cmdline() -> Dict[str, str]: + 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} @@ -30,6 +29,10 @@ class HardwareBase(ABC): def reboot(self, reason=None): pass + @abstractmethod + def soft_reboot(self): + pass + @abstractmethod def uninstall(self): pass diff --git a/system/hardware/hw.h b/system/hardware/hw.h new file mode 100644 index 0000000..394807c --- /dev/null +++ b/system/hardware/hw.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include "system/hardware/base.h" +#include "common/util.h" + +#if QCOM2 +#include "system/hardware/tici/hardware.h" +#define Hardware HardwareTici +#else +#include "system/hardware/pc/hardware.h" +#define Hardware HardwarePC +#endif + +namespace Path { + inline std::string openpilot_prefix() { + return util::getenv("OPENPILOT_PREFIX", ""); + } + + inline std::string comma_home() { + return util::getenv("HOME") + "/.comma" + Path::openpilot_prefix(); + } + + inline std::string log_root() { + if (const char *env = getenv("LOG_ROOT")) { + return env; + } + return Hardware::PC() ? Path::comma_home() + "/media/0/realdata" : "/data/media/0/realdata"; + } + + inline std::string params() { + return util::getenv("PARAMS_ROOT", Hardware::PC() ? (Path::comma_home() + "/params") : "/data/params"); + } + + inline std::string rsa_file() { + return Hardware::PC() ? Path::comma_home() + "/persist/comma/id_rsa" : "/persist/comma/id_rsa"; + } + + inline std::string swaglog_ipc() { + return "ipc:///tmp/logmessage" + Path::openpilot_prefix(); + } + + inline std::string download_cache_root() { + if (const char *env = getenv("COMMA_CACHE")) { + return env; + } + return "/tmp/comma_download_cache" + Path::openpilot_prefix() + "/"; + } +} // namespace Path diff --git a/system/hardware/pc/hardware.h b/system/hardware/pc/hardware.h new file mode 100644 index 0000000..5dea184 --- /dev/null +++ b/system/hardware/pc/hardware.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "system/hardware/base.h" + +class HardwarePC : public HardwareNone { +public: + static std::string get_os_version() { return "openpilot for PC"; } + static std::string get_name() { return "pc"; } + static cereal::InitData::DeviceType get_device_type() { return cereal::InitData::DeviceType::PC; } + static bool PC() { return true; } + static bool TICI() { return util::getenv("TICI", 0) == 1; } + static bool AGNOS() { return util::getenv("TICI", 0) == 1; } + + static void config_cpu_rendering(bool offscreen) { + if (offscreen) { + setenv("QT_QPA_PLATFORM", "offscreen", 1); + } + setenv("__GLX_VENDOR_LIBRARY_NAME", "mesa", 1); + setenv("LP_NUM_THREADS", "0", 1); // disable threading so we stay on our assigned CPU + } +}; diff --git a/system/hardware/pc/hardware.py b/system/hardware/pc/hardware.py index 719e272..2a12e85 100644 --- a/system/hardware/pc/hardware.py +++ b/system/hardware/pc/hardware.py @@ -20,6 +20,9 @@ class Pc(HardwareBase): def reboot(self, reason=None): print("REBOOT!") + def soft_reboot(self): + print("SOFT REBOOT!") + def uninstall(self): print("uninstall") diff --git a/system/hardware/tici/agnos.py b/system/hardware/tici/agnos.py index 342281b..502295b 100755 --- a/system/hardware/tici/agnos.py +++ b/system/hardware/tici/agnos.py @@ -6,7 +6,7 @@ import os import struct import subprocess import time -from typing import Dict, Generator, List, Tuple, Union +from collections.abc import Generator import requests @@ -117,7 +117,7 @@ def get_raw_hash(path: str, partition_size: int) -> str: return raw_hash.hexdigest().lower() -def verify_partition(target_slot_number: int, partition: Dict[str, Union[str, int]], force_full_check: bool = False) -> bool: +def verify_partition(target_slot_number: int, partition: dict[str, str | int], force_full_check: bool = False) -> bool: full_check = partition['full_check'] or force_full_check path = get_partition_path(target_slot_number, partition) @@ -184,7 +184,7 @@ def extract_casync_image(target_slot_number: int, partition: dict, cloudlog): target = casync.parse_caibx(partition['casync_caibx']) - sources: List[Tuple[str, casync.ChunkReader, casync.ChunkDict]] = [] + sources: list[tuple[str, casync.ChunkReader, casync.ChunkDict]] = [] # First source is the current partition. try: diff --git a/system/hardware/tici/amplifier.py b/system/hardware/tici/amplifier.py index e003f13..af82067 100755 --- a/system/hardware/tici/amplifier.py +++ b/system/hardware/tici/amplifier.py @@ -2,7 +2,6 @@ import time from smbus2 import SMBus from collections import namedtuple -from typing import List # https://datasheets.maximintegrated.com/en/ds/MAX98089.pdf @@ -110,7 +109,7 @@ class Amplifier: 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: + def _set_configs(self, configs: list[AmpConfig]) -> None: with SMBus(self.AMP_I2C_BUS) as bus: for config in configs: if self.debug: @@ -123,7 +122,7 @@ class Amplifier: if self.debug: print(f" Changed {hex(config.register)}: {hex(old_value)} -> {hex(new_value)}") - def set_configs(self, configs: List[AmpConfig]) -> bool: + def set_configs(self, configs: list[AmpConfig]) -> bool: # retry in case panda is using the amp tries = 15 for i in range(15): diff --git a/system/hardware/tici/casync.py b/system/hardware/tici/casync.py index 9933366..986228c 100755 --- a/system/hardware/tici/casync.py +++ b/system/hardware/tici/casync.py @@ -7,7 +7,7 @@ import sys import time from abc import ABC, abstractmethod from collections import defaultdict, namedtuple -from typing import Callable, Dict, List, Optional, Tuple +from collections.abc import Callable import requests from Crypto.Hash import SHA512 @@ -28,7 +28,7 @@ CHUNK_DOWNLOAD_RETRIES = 3 CAIBX_DOWNLOAD_TIMEOUT = 120 Chunk = namedtuple('Chunk', ['sha', 'offset', 'length']) -ChunkDict = Dict[bytes, Chunk] +ChunkDict = dict[bytes, Chunk] class ChunkReader(ABC): @@ -83,7 +83,7 @@ class RemoteChunkReader(ChunkReader): return decompressor.decompress(contents) -def parse_caibx(caibx_path: str) -> List[Chunk]: +def parse_caibx(caibx_path: str) -> list[Chunk]: """Parses the chunks from a caibx file. Can handle both local and remote files. Returns a list of chunks with hash, offset and length""" caibx: io.BufferedIOBase @@ -132,7 +132,7 @@ def parse_caibx(caibx_path: str) -> List[Chunk]: return chunks -def build_chunk_dict(chunks: List[Chunk]) -> ChunkDict: +def build_chunk_dict(chunks: list[Chunk]) -> ChunkDict: """Turn a list of chunks into a dict for faster lookups based on hash. Keep first chunk since it's more likely to be already downloaded.""" r = {} @@ -142,11 +142,11 @@ def build_chunk_dict(chunks: List[Chunk]) -> ChunkDict: return r -def extract(target: List[Chunk], - sources: List[Tuple[str, ChunkReader, ChunkDict]], +def extract(target: list[Chunk], + sources: list[tuple[str, ChunkReader, ChunkDict]], out_path: str, - progress: Optional[Callable[[int], None]] = None): - stats: Dict[str, int] = defaultdict(int) + progress: Callable[[int], None] = None): + stats: dict[str, int] = defaultdict(int) mode = 'rb+' if os.path.exists(out_path) else 'wb' with open(out_path, mode) as out: @@ -181,7 +181,7 @@ def extract(target: List[Chunk], return stats -def print_stats(stats: Dict[str, int]): +def print_stats(stats: dict[str, int]): total_bytes = sum(stats.values()) print(f"Total size: {total_bytes / 1024 / 1024:.2f} MB") for name, total in stats.items(): diff --git a/system/hardware/tici/esim.py b/system/hardware/tici/esim.py new file mode 100644 index 0000000..df76c1a --- /dev/null +++ b/system/hardware/tici/esim.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import os +import math +import time +import binascii +import requests +import serial +import subprocess + + +def post(url, payload): + print() + print("POST to", url) + r = requests.post( + url, + data=payload, + verify=False, + headers={ + "Content-Type": "application/json", + "X-Admin-Protocol": "gsma/rsp/v2.2.0", + "charset": "utf-8", + "User-Agent": "gsma-rsp-lpad", + }, + ) + print("resp", r) + print("resp text", repr(r.text)) + print() + r.raise_for_status() + + ret = f"HTTP/1.1 {r.status_code}" + ret += ''.join(f"{k}: {v}" for k, v in r.headers.items() if k != 'Connection') + return ret.encode() + r.content + + +class LPA: + def __init__(self): + self.dev = serial.Serial('/dev/ttyUSB2', baudrate=57600, timeout=1, bytesize=8) + self.dev.reset_input_buffer() + self.dev.reset_output_buffer() + assert "OK" in self.at("AT") + + def at(self, cmd): + print(f"==> {cmd}") + self.dev.write(cmd.encode() + b'\r\n') + + r = b"" + cnt = 0 + while b"OK" not in r and b"ERROR" not in r and cnt < 20: + r += self.dev.read(8192).strip() + cnt += 1 + r = r.decode() + print(f"<== {repr(r)}") + return r + + def download_ota(self, qr): + return self.at(f'AT+QESIM="ota","{qr}"') + + def download(self, qr): + smdp = qr.split('$')[1] + out = self.at(f'AT+QESIM="download","{qr}"') + for _ in range(5): + line = out.split("+QESIM: ")[1].split("\r\n\r\nOK")[0] + + parts = [x.strip().strip('"') for x in line.split(',', maxsplit=4)] + print(repr(parts)) + trans, ret, url, payloadlen, payload = parts + assert trans == "trans" and ret == "0" + assert len(payload) == int(payloadlen) + + r = post(f"https://{smdp}/{url}", payload) + to_send = binascii.hexlify(r).decode() + + chunk_len = 1400 + for i in range(math.ceil(len(to_send) / chunk_len)): + state = 1 if (i+1)*chunk_len < len(to_send) else 0 + data = to_send[i * chunk_len : (i+1)*chunk_len] + out = self.at(f'AT+QESIM="trans",{len(to_send)},{state},{i},{len(data)},"{data}"') + assert "OK" in out + + if '+QESIM:"download",1' in out: + raise Exception("profile install failed") + elif '+QESIM:"download",0' in out: + print("done, successfully loaded") + break + + def enable(self, iccid): + self.at(f'AT+QESIM="enable","{iccid}"') + + def disable(self, iccid): + self.at(f'AT+QESIM="disable","{iccid}"') + + def delete(self, iccid): + self.at(f'AT+QESIM="delete","{iccid}"') + + def list_profiles(self): + out = self.at('AT+QESIM="list"') + return out.strip().splitlines()[1:] + + +if __name__ == "__main__": + import sys + + if "RESTART" in os.environ: + subprocess.check_call("sudo systemctl stop ModemManager", shell=True) + subprocess.check_call("/usr/comma/lte/lte.sh stop_blocking", shell=True) + subprocess.check_call("/usr/comma/lte/lte.sh start", shell=True) + while not os.path.exists('/dev/ttyUSB2'): + time.sleep(1) + time.sleep(3) + + lpa = LPA() + print(lpa.list_profiles()) + if len(sys.argv) > 1: + lpa.download(sys.argv[1]) + print(lpa.list_profiles()) diff --git a/system/hardware/tici/hardware.h b/system/hardware/tici/hardware.h new file mode 100644 index 0000000..c8d88be --- /dev/null +++ b/system/hardware/tici/hardware.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include + +#include "common/params.h" +#include "common/util.h" +#include "system/hardware/base.h" + +class HardwareTici : public HardwareNone { +public: + static constexpr float MAX_VOLUME = 0.9; + static constexpr float MIN_VOLUME = 0.1; + static bool TICI() { return true; } + static bool AGNOS() { return true; } + static std::string get_os_version() { + return "AGNOS " + util::read_file("/VERSION"); + } + + static std::string get_name() { + std::string model = util::read_file("/sys/firmware/devicetree/base/model"); + return model.substr(std::string("comma ").size()); + } + + static cereal::InitData::DeviceType get_device_type() { + return (get_name() == "tizi") ? cereal::InitData::DeviceType::TIZI : (get_name() == "mici" ? cereal::InitData::DeviceType::MICI : cereal::InitData::DeviceType::TICI); + } + + static int get_voltage() { return std::atoi(util::read_file("/sys/class/hwmon/hwmon1/in1_input").c_str()); } + static int get_current() { return std::atoi(util::read_file("/sys/class/hwmon/hwmon1/curr1_input").c_str()); } + + static std::string get_serial() { + static std::string serial(""); + if (serial.empty()) { + std::ifstream stream("/proc/cmdline"); + std::string cmdline; + std::getline(stream, cmdline); + + auto start = cmdline.find("serialno="); + if (start == std::string::npos) { + serial = "cccccc"; + } else { + auto end = cmdline.find(" ", start + 9); + serial = cmdline.substr(start + 9, end - start - 9); + } + } + return serial; + } + + static void reboot() { std::system("sudo reboot"); } + static void soft_reboot() { + const std::vector commands = { + "rm -f /tmp/safe_staging_overlay.lock", + "tmux new -s commatmp -d '/data/continue.sh'", + "tmux kill-session -t comma", + "tmux rename comma" + }; + for (const auto& cmd : commands) { + int result; + do { + result = std::system(cmd.c_str()); + } while (result != 0); + if (result != 0) { + reboot(); + } + } + } + static void poweroff() { std::system("sudo poweroff"); } + static void set_brightness(int percent) { + std::string max = util::read_file("/sys/class/backlight/panel0-backlight/max_brightness"); + + std::ofstream brightness_control("/sys/class/backlight/panel0-backlight/brightness"); + if (brightness_control.is_open()) { + brightness_control << (int)(percent * (std::stof(max)/100.)) << "\n"; + brightness_control.close(); + } + } + static void set_display_power(bool on) { + std::ofstream bl_power_control("/sys/class/backlight/panel0-backlight/bl_power"); + if (bl_power_control.is_open()) { + bl_power_control << (on ? "0" : "4") << "\n"; + bl_power_control.close(); + } + } + + static std::map get_init_logs() { + std::map ret = { + {"/BUILD", util::read_file("/BUILD")}, + {"lsblk", util::check_output("lsblk -o NAME,SIZE,STATE,VENDOR,MODEL,REV,SERIAL")}, + {"SOM ID", util::read_file("/sys/devices/platform/vendor/vendor:gpio-som-id/som_id")}, + }; + + std::string bs = util::check_output("abctl --boot_slot"); + ret["boot slot"] = bs.substr(0, bs.find_first_of("\n")); + + std::string temp = util::read_file("/dev/disk/by-partlabel/ssd"); + temp.erase(temp.find_last_not_of(std::string("\0\r\n", 3))+1); + ret["boot temp"] = temp; + + // TODO: log something from system and boot + for (std::string part : {"xbl", "abl", "aop", "devcfg", "xbl_config"}) { + for (std::string slot : {"a", "b"}) { + std::string partition = part + "_" + slot; + std::string hash = util::check_output("sha256sum /dev/disk/by-partlabel/" + partition); + ret[partition] = hash.substr(0, hash.find_first_of(" ")); + } + } + + return ret; + } + + static bool get_ssh_enabled() { return Params().getBool("SshEnabled"); } + static void set_ssh_enabled(bool enabled) { Params().putBool("SshEnabled", enabled); } + + static void config_cpu_rendering(bool offscreen) { + if (offscreen) { + setenv("QT_QPA_PLATFORM", "eglfs", 1); // offscreen doesn't work with EGL/GLES + } + setenv("LP_NUM_THREADS", "0", 1); // disable threading so we stay on our assigned CPU + } +}; diff --git a/system/hardware/tici/hardware.py b/system/hardware/tici/hardware.py index 5bb1032..115d162 100644 --- a/system/hardware/tici/hardware.py +++ b/system/hardware/tici/hardware.py @@ -94,11 +94,7 @@ def get_device_type(): # lru_cache and cache can cause memory leaks when used in classes with open("/sys/firmware/devicetree/base/model") as f: model = f.read().strip('\x00') - model = model.split('comma ')[-1] - # TODO: remove this with AGNOS 7+ - if model.startswith('Qualcomm'): - model = 'tici' - return model + return model.split('comma ')[-1] class Tici(HardwareBase): @cached_property @@ -116,6 +112,8 @@ class Tici(HardwareBase): @cached_property def amplifier(self): + if self.get_device_type() == "mici": + return None return Amplifier() def get_os_version(self): @@ -134,6 +132,16 @@ class Tici(HardwareBase): def reboot(self, reason=None): subprocess.check_output(["sudo", "reboot"]) + def soft_reboot(self): + commands = [ + ['rm', '-f', '/tmp/safe_staging_overlay.lock'], + ['tmux', 'new', '-s', 'commatmp', '-d', '/data/continue.sh'], + ['tmux', 'kill-session', '-t', 'comma'], + ['tmux', 'rename', 'comma'], + ] + for command in commands: + subprocess.run(command, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) + def uninstall(self): Path("/data/__system_reset__").touch() os.sync() @@ -374,9 +382,10 @@ class Tici(HardwareBase): def set_power_save(self, powersave_enabled): # amplifier, 100mW at idle - self.amplifier.set_global_shutdown(amp_disabled=powersave_enabled) - if not powersave_enabled: - self.amplifier.initialize_configuration(self.get_device_type()) + 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 *** @@ -414,7 +423,8 @@ class Tici(HardwareBase): return 0 def initialize_hardware(self): - self.amplifier.initialize_configuration(self.get_device_type()) + if self.amplifier is not None: + self.amplifier.initialize_configuration(self.get_device_type()) # Allow thermald to write engagement status to kmsg os.system("sudo chmod a+w /dev/kmsg") @@ -468,8 +478,9 @@ class Tici(HardwareBase): # use sim slot 'AT^SIMSWAP=1', - # configure ECM mode - 'AT$QCPCFG=usbNet,1' + # ethernet config + 'AT$QCPCFG=usbNet,0', + 'AT$QCNETDEVCTL=3,1', ] else: cmds += [ @@ -478,6 +489,12 @@ class Tici(HardwareBase): '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 += [ + # SIM hot swap + 'AT+QSIMDET=1,0', + 'AT+QSIMSTAT=1', + ] # clear out old blue prime initial APN os.system('mmcli -m any --3gpp-set-initial-eps-bearer-settings="apn="') diff --git a/system/hardware/tici/power_monitor.py b/system/hardware/tici/power_monitor.py new file mode 100644 index 0000000..296290d --- /dev/null +++ b/system/hardware/tici/power_monitor.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import sys +import time +import datetime +import numpy as np +from collections import deque + +from openpilot.common.realtime import Ratekeeper +from openpilot.common.filter_simple import FirstOrderFilter + + +def read_power(): + with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: + return int(f.read()) / 1e6 + +def sample_power(seconds=5) -> list[float]: + rate = 123 + rk = Ratekeeper(rate, print_delay_threshold=None) + + pwrs = [] + for _ in range(rate*seconds): + pwrs.append(read_power()) + rk.keep_time() + return pwrs + +def get_power(seconds=5): + pwrs = sample_power(seconds) + return np.mean(pwrs) + +def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): + start_time = time.monotonic() + pwrs = deque([min_pwr - 1.]*min_secs_in_range, maxlen=min_secs_in_range) + while (time.monotonic() - start_time < timeout): + pwrs.append(get_power(1)) + if all(min_pwr <= p <= max_pwr for p in pwrs): + break + return np.mean(pwrs) + + +if __name__ == "__main__": + duration = None + if len(sys.argv) > 1: + duration = int(sys.argv[1]) + + rate = 23 + rk = Ratekeeper(rate, print_delay_threshold=None) + fltr = FirstOrderFilter(0, 5, 1. / rate, initialized=False) + + measurements = [] + start_time = time.monotonic() + + try: + while duration is None or time.monotonic() - start_time < duration: + fltr.update(read_power()) + if rk.frame % rate == 0: + measurements.append(fltr.x) + t = datetime.timedelta(seconds=time.monotonic() - start_time) + avg = sum(measurements) / len(measurements) + print(f"Now: {fltr.x:.2f} W, Avg: {avg:.2f} W over {t}") + rk.keep_time() + except KeyboardInterrupt: + pass + + t = datetime.timedelta(seconds=time.monotonic() - start_time) + avg = sum(measurements) / len(measurements) + print(f"\nAverage power: {avg:.2f}W over {t}") diff --git a/system/hardware/tici/precise_power_measure.py b/system/hardware/tici/precise_power_measure.py new file mode 100644 index 0000000..52fe085 --- /dev/null +++ b/system/hardware/tici/precise_power_measure.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +import numpy as np +from openpilot.system.hardware.tici.power_monitor import sample_power + +if __name__ == '__main__': + print("measuring for 5 seconds") + for _ in range(3): + pwrs = sample_power() + print(f"mean {np.mean(pwrs):.2f} std {np.std(pwrs):.2f}") diff --git a/system/hardware/tici/restart_modem.sh b/system/hardware/tici/restart_modem.sh new file mode 100644 index 0000000..3c67d9d --- /dev/null +++ b/system/hardware/tici/restart_modem.sh @@ -0,0 +1,18 @@ +#!/usr/bin/bash + +#nmcli connection modify --temporary lte gsm.home-only yes +#nmcli connection modify --temporary lte gsm.auto-config yes +#nmcli connection modify --temporary lte connection.autoconnect-retries 20 +sudo nmcli connection reload + +sudo systemctl stop ModemManager +nmcli con down lte +nmcli con down blue-prime + +# power cycle modem +/usr/comma/lte/lte.sh stop_blocking +/usr/comma/lte/lte.sh start + +sudo systemctl restart NetworkManager +#sudo systemctl restart ModemManager +sudo ModemManager --debug diff --git a/system/hardware/tici/tests/__init__.py b/system/hardware/tici/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system/hardware/tici/tests/compare_casync_manifest.py b/system/hardware/tici/tests/compare_casync_manifest.py new file mode 100644 index 0000000..7de66d9 --- /dev/null +++ b/system/hardware/tici/tests/compare_casync_manifest.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import argparse +import collections +import multiprocessing +import os + +import requests +from tqdm import tqdm + +import openpilot.system.hardware.tici.casync as casync + + +def get_chunk_download_size(chunk): + sha = chunk.sha.hex() + path = os.path.join(remote_url, sha[:4], sha + ".cacnk") + if os.path.isfile(path): + return os.path.getsize(path) + else: + r = requests.head(path, timeout=10) + r.raise_for_status() + return int(r.headers['content-length']) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description='Compute overlap between two casync manifests') + parser.add_argument('frm') + parser.add_argument('to') + args = parser.parse_args() + + frm = casync.parse_caibx(args.frm) + to = casync.parse_caibx(args.to) + remote_url = args.to.replace('.caibx', '') + + most_common = collections.Counter(t.sha for t in to).most_common(1)[0][0] + + frm_dict = casync.build_chunk_dict(frm) + + # Get content-length for each chunk + with multiprocessing.Pool() as pool: + szs = list(tqdm(pool.imap(get_chunk_download_size, to), total=len(to))) + chunk_sizes = {t.sha: sz for (t, sz) in zip(to, szs, strict=True)} + + sources: dict[str, list[int]] = { + 'seed': [], + 'remote_uncompressed': [], + 'remote_compressed': [], + } + + for chunk in to: + # Assume most common chunk is the zero chunk + if chunk.sha == most_common: + continue + + if chunk.sha in frm_dict: + sources['seed'].append(chunk.length) + else: + sources['remote_uncompressed'].append(chunk.length) + sources['remote_compressed'].append(chunk_sizes[chunk.sha]) + + print() + print("Update statistics (excluding zeros)") + print() + print("Download only with no seed:") + print(f" Remote (uncompressed)\t\t{sum(sources['seed'] + sources['remote_uncompressed']) / 1000 / 1000:.2f} MB\tn = {len(to)}") + print(f" Remote (compressed download)\t{sum(chunk_sizes.values()) / 1000 / 1000:.2f} MB\tn = {len(to)}") + print() + print("Upgrade with seed partition:") + print(f" Seed (uncompressed)\t\t{sum(sources['seed']) / 1000 / 1000:.2f} MB\t\t\t\tn = {len(sources['seed'])}") + sz, n = sum(sources['remote_uncompressed']), len(sources['remote_uncompressed']) + print(f" Remote (uncompressed)\t\t{sz / 1000 / 1000:.2f} MB\t(avg {sz / 1000 / 1000 / n:4f} MB)\tn = {n}") + sz, n = sum(sources['remote_compressed']), len(sources['remote_compressed']) + print(f" Remote (compressed download)\t{sz / 1000 / 1000:.2f} MB\t(avg {sz / 1000 / 1000 / n:4f} MB)\tn = {n}") diff --git a/system/hardware/tici/tests/test_agnos_updater.py b/system/hardware/tici/tests/test_agnos_updater.py new file mode 100644 index 0000000..86bc788 --- /dev/null +++ b/system/hardware/tici/tests/test_agnos_updater.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import json +import os +import unittest +import requests + +TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) +MANIFEST = os.path.join(TEST_DIR, "../agnos.json") + + +class TestAgnosUpdater(unittest.TestCase): + + def test_manifest(self): + with open(MANIFEST) as f: + m = json.load(f) + + for img in m: + r = requests.head(img['url'], timeout=10) + r.raise_for_status() + self.assertEqual(r.headers['Content-Type'], "application/x-xz") + if not img['sparse']: + assert img['hash'] == img['hash_raw'] + + +if __name__ == "__main__": + unittest.main() diff --git a/system/hardware/tici/tests/test_amplifier.py b/system/hardware/tici/tests/test_amplifier.py new file mode 100644 index 0000000..cd3b0f9 --- /dev/null +++ b/system/hardware/tici/tests/test_amplifier.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import time +import random +import unittest +import subprocess + +from panda import Panda +from openpilot.system.hardware import TICI, HARDWARE +from openpilot.system.hardware.tici.hardware import Tici +from openpilot.system.hardware.tici.amplifier import Amplifier + + +class TestAmplifier(unittest.TestCase): + + @classmethod + def setUpClass(cls): + if not TICI: + raise unittest.SkipTest + + def setUp(self): + # clear dmesg + subprocess.check_call("sudo dmesg -C", shell=True) + + HARDWARE.reset_internal_panda() + Panda.wait_for_panda(None, 30) + self.panda = Panda() + + def tearDown(self): + HARDWARE.reset_internal_panda() + + def _check_for_i2c_errors(self, expected): + dmesg = subprocess.check_output("dmesg", shell=True, encoding='utf8') + i2c_lines = [l for l in dmesg.strip().splitlines() if 'i2c_geni a88000.i2c' in l] + i2c_str = '\n'.join(i2c_lines) + + if not expected: + return len(i2c_lines) == 0 + else: + return "i2c error :-107" in i2c_str or "Bus arbitration lost" in i2c_str + + def test_init(self): + amp = Amplifier(debug=True) + r = amp.initialize_configuration(Tici().get_device_type()) + assert r + assert self._check_for_i2c_errors(False) + + def test_shutdown(self): + amp = Amplifier(debug=True) + for _ in range(10): + r = amp.set_global_shutdown(True) + r = amp.set_global_shutdown(False) + # amp config should be successful, with no i2c errors + assert r + assert self._check_for_i2c_errors(False) + + def test_init_while_siren_play(self): + for _ in range(10): + self.panda.set_siren(False) + time.sleep(0.1) + + self.panda.set_siren(True) + time.sleep(random.randint(0, 5)) + + amp = Amplifier(debug=True) + r = amp.initialize_configuration(Tici().get_device_type()) + assert r + + if self._check_for_i2c_errors(True): + break + else: + self.fail("didn't hit any i2c errors") + + +if __name__ == "__main__": + unittest.main() diff --git a/system/hardware/tici/tests/test_casync.py b/system/hardware/tici/tests/test_casync.py new file mode 100644 index 0000000..94b32a9 --- /dev/null +++ b/system/hardware/tici/tests/test_casync.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +import os +import unittest +import tempfile +import subprocess + +import openpilot.system.hardware.tici.casync as casync + +# dd if=/dev/zero of=/tmp/img.raw bs=1M count=2 +# sudo losetup -f /tmp/img.raw +# losetup -a | grep img.raw +LOOPBACK = os.environ.get('LOOPBACK', None) + + +class TestCasync(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmpdir = tempfile.TemporaryDirectory() + + # Build example contents + chunk_a = [i % 256 for i in range(1024)] * 512 + chunk_b = [(256 - i) % 256 for i in range(1024)] * 512 + zeroes = [0] * (1024 * 128) + contents = chunk_a + chunk_b + zeroes + chunk_a + + cls.contents = bytes(contents) + + # Write to file + cls.orig_fn = os.path.join(cls.tmpdir.name, 'orig.bin') + with open(cls.orig_fn, 'wb') as f: + f.write(cls.contents) + + # Create casync files + cls.manifest_fn = os.path.join(cls.tmpdir.name, 'orig.caibx') + cls.store_fn = os.path.join(cls.tmpdir.name, 'store') + subprocess.check_output(["casync", "make", "--compression=xz", "--store", cls.store_fn, cls.manifest_fn, cls.orig_fn]) + + target = casync.parse_caibx(cls.manifest_fn) + hashes = [c.sha.hex() for c in target] + + # Ensure we have chunk reuse + assert len(hashes) > len(set(hashes)) + + def setUp(self): + # Clear target_lo + if LOOPBACK is not None: + self.target_lo = LOOPBACK + with open(self.target_lo, 'wb') as f: + f.write(b"0" * len(self.contents)) + + self.target_fn = os.path.join(self.tmpdir.name, next(tempfile._get_candidate_names())) + self.seed_fn = os.path.join(self.tmpdir.name, next(tempfile._get_candidate_names())) + + def tearDown(self): + for fn in [self.target_fn, self.seed_fn]: + try: + os.unlink(fn) + except FileNotFoundError: + pass + + def test_simple_extract(self): + target = casync.parse_caibx(self.manifest_fn) + + sources = [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + stats = casync.extract(target, sources, self.target_fn) + + with open(self.target_fn, 'rb') as target_f: + self.assertEqual(target_f.read(), self.contents) + + self.assertEqual(stats['remote'], len(self.contents)) + + def test_seed(self): + target = casync.parse_caibx(self.manifest_fn) + + # Populate seed with half of the target contents + with open(self.seed_fn, 'wb') as seed_f: + seed_f.write(self.contents[:len(self.contents) // 2]) + + sources = [('seed', casync.FileChunkReader(self.seed_fn), casync.build_chunk_dict(target))] + sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + stats = casync.extract(target, sources, self.target_fn) + + with open(self.target_fn, 'rb') as target_f: + self.assertEqual(target_f.read(), self.contents) + + self.assertGreater(stats['seed'], 0) + self.assertLess(stats['remote'], len(self.contents)) + + def test_already_done(self): + """Test that an already flashed target doesn't download any chunks""" + target = casync.parse_caibx(self.manifest_fn) + + with open(self.target_fn, 'wb') as f: + f.write(self.contents) + + sources = [('target', casync.FileChunkReader(self.target_fn), casync.build_chunk_dict(target))] + sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + + stats = casync.extract(target, sources, self.target_fn) + + with open(self.target_fn, 'rb') as f: + self.assertEqual(f.read(), self.contents) + + self.assertEqual(stats['target'], len(self.contents)) + + def test_chunk_reuse(self): + """Test that chunks that are reused are only downloaded once""" + target = casync.parse_caibx(self.manifest_fn) + + # Ensure target exists + with open(self.target_fn, 'wb'): + pass + + sources = [('target', casync.FileChunkReader(self.target_fn), casync.build_chunk_dict(target))] + sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + + stats = casync.extract(target, sources, self.target_fn) + + with open(self.target_fn, 'rb') as f: + self.assertEqual(f.read(), self.contents) + + self.assertLess(stats['remote'], len(self.contents)) + + @unittest.skipUnless(LOOPBACK, "requires loopback device") + def test_lo_simple_extract(self): + target = casync.parse_caibx(self.manifest_fn) + sources = [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + + stats = casync.extract(target, sources, self.target_lo) + + with open(self.target_lo, 'rb') as target_f: + self.assertEqual(target_f.read(len(self.contents)), self.contents) + + self.assertEqual(stats['remote'], len(self.contents)) + + @unittest.skipUnless(LOOPBACK, "requires loopback device") + def test_lo_chunk_reuse(self): + """Test that chunks that are reused are only downloaded once""" + target = casync.parse_caibx(self.manifest_fn) + + sources = [('target', casync.FileChunkReader(self.target_lo), casync.build_chunk_dict(target))] + sources += [('remote', casync.RemoteChunkReader(self.store_fn), casync.build_chunk_dict(target))] + + stats = casync.extract(target, sources, self.target_lo) + + with open(self.target_lo, 'rb') as f: + self.assertEqual(f.read(len(self.contents)), self.contents) + + self.assertLess(stats['remote'], len(self.contents)) + + +if __name__ == "__main__": + unittest.main() diff --git a/system/hardware/tici/tests/test_hardware.py b/system/hardware/tici/tests/test_hardware.py new file mode 100644 index 0000000..6c41c38 --- /dev/null +++ b/system/hardware/tici/tests/test_hardware.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import pytest +import time +import unittest +import numpy as np + +from openpilot.system.hardware.tici.hardware import Tici + +HARDWARE = Tici() + + +@pytest.mark.tici +class TestHardware(unittest.TestCase): + + def test_power_save_time(self): + ts = [] + for _ in range(5): + for on in (True, False): + st = time.monotonic() + HARDWARE.set_power_save(on) + ts.append(time.monotonic() - st) + + assert 0.1 < np.mean(ts) < 0.25 + assert max(ts) < 0.3 + + +if __name__ == "__main__": + unittest.main() diff --git a/system/hardware/tici/tests/test_power_draw.py b/system/hardware/tici/tests/test_power_draw.py new file mode 100644 index 0000000..180ec15 --- /dev/null +++ b/system/hardware/tici/tests/test_power_draw.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +from collections import defaultdict, deque +import pytest +import unittest +import time +import numpy as np +from dataclasses import dataclass +from tabulate import tabulate + +import cereal.messaging as messaging +from cereal.services import SERVICE_LIST +from openpilot.common.mock import mock_messages +from openpilot.selfdrive.car.car_helpers import write_car_param +from openpilot.system.hardware.tici.power_monitor import get_power +from openpilot.selfdrive.manager.process_config import managed_processes +from openpilot.selfdrive.manager.manager import manager_cleanup + +SAMPLE_TIME = 8 # seconds to sample power +MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples + +@dataclass +class Proc: + procs: list[str] + power: float + msgs: list[str] + rtol: float = 0.05 + atol: float = 0.12 + + @property + def name(self): + return '+'.join(self.procs) + + +PROCS = [ + Proc(['camerad'], 2.1, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), + Proc(['modeld'], 1.12, atol=0.2, msgs=['modelV2']), + Proc(['dmonitoringmodeld'], 0.4, msgs=['driverStateV2']), + Proc(['encoderd'], 0.23, msgs=[]), + Proc(['mapsd', 'navmodeld'], 0.05, msgs=['mapRenderState', 'navModel']), +] + + +@pytest.mark.tici +class TestPowerDraw(unittest.TestCase): + + def setUp(self): + write_car_param() + + # wait a bit for power save to disable + time.sleep(5) + + def tearDown(self): + manager_cleanup() + + def get_expected_messages(self, proc): + return int(sum(SAMPLE_TIME * SERVICE_LIST[msg].frequency for msg in proc.msgs)) + + def valid_msg_count(self, proc, msg_counts): + msgs_received = sum(msg_counts[msg] for msg in proc.msgs) + msgs_expected = self.get_expected_messages(proc) + return np.core.numeric.isclose(msgs_expected, msgs_received, rtol=.02, atol=2) + + def valid_power_draw(self, proc, used): + return np.core.numeric.isclose(used, proc.power, rtol=proc.rtol, atol=proc.atol) + + def tabulate_msg_counts(self, msgs_and_power): + msg_counts = defaultdict(int) + for _, counts in msgs_and_power: + for msg, count in counts.items(): + msg_counts[msg] += count + return msg_counts + + def get_power_with_warmup_for_target(self, proc, prev): + socks = {msg: messaging.sub_sock(msg) for msg in proc.msgs} + for sock in socks.values(): + messaging.drain_sock_raw(sock) + + msgs_and_power = deque([], maxlen=SAMPLE_TIME) + + start_time = time.monotonic() + + while (time.monotonic() - start_time) < MAX_WARMUP_TIME: + power = get_power(1) + iteration_msg_counts = {} + for msg,sock in socks.items(): + iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) + msgs_and_power.append((power, iteration_msg_counts)) + + if len(msgs_and_power) < SAMPLE_TIME: + continue + + msg_counts = self.tabulate_msg_counts(msgs_and_power) + now = np.mean([m[0] for m in msgs_and_power]) + + if self.valid_msg_count(proc, msg_counts) and self.valid_power_draw(proc, now - prev): + break + + return now, msg_counts, time.monotonic() - start_time - SAMPLE_TIME + + @mock_messages(['liveLocationKalman']) + def test_camera_procs(self): + baseline = get_power() + + prev = baseline + used = {} + warmup_time = {} + msg_counts = {} + + for proc in PROCS: + for p in proc.procs: + managed_processes[p].start() + now, local_msg_counts, warmup_time[proc.name] = self.get_power_with_warmup_for_target(proc, prev) + msg_counts.update(local_msg_counts) + + used[proc.name] = now - prev + prev = now + + manager_cleanup() + + tab = [['process', 'expected (W)', 'measured (W)', '# msgs expected', '# msgs received', "warmup time (s)"]] + for proc in PROCS: + cur = used[proc.name] + expected = proc.power + msgs_received = sum(msg_counts[msg] for msg in proc.msgs) + tab.append([proc.name, round(expected, 2), round(cur, 2), self.get_expected_messages(proc), msgs_received, round(warmup_time[proc.name], 2)]) + with self.subTest(proc=proc.name): + self.assertTrue(self.valid_msg_count(proc, msg_counts), f"expected {self.get_expected_messages(proc)} msgs, got {msgs_received} msgs") + self.assertTrue(self.valid_power_draw(proc, cur), f"expected {expected:.2f}W, got {cur:.2f}W") + print(tabulate(tab)) + print(f"Baseline {baseline:.2f}W\n") + + +if __name__ == "__main__": + unittest.main()