IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
0
iqpilot/system/hardware/tests/__init__.py
Normal file
0
iqpilot/system/hardware/tests/__init__.py
Normal file
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
132
iqpilot/system/hardware/tests/test_egpu_dock_flash.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
The eGPU dock flasher writes SPI flash, so the parts that decide WHETHER and
|
||||
WHAT to write are pinned here. The transfer path itself needs the hardware; the
|
||||
image validator, product parsing, config preservation and the needs-flash
|
||||
decision do not, and those are what stop a bad write.
|
||||
"""
|
||||
import os
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock import flash as f
|
||||
|
||||
|
||||
def _wrap(body: bytes, *, magic=0xA5, checksum=None, crc=None, body_len=None) -> bytes:
|
||||
n = len(body) if body_len is None else body_len
|
||||
cs = (sum(body) & 0xFF) if checksum is None else checksum
|
||||
c = zlib.crc32(body).to_bytes(4, "little") if crc is None else crc
|
||||
return n.to_bytes(4, "little") + body + bytes([magic, cs]) + c
|
||||
|
||||
|
||||
def test_bundled_firmware_is_valid_and_named():
|
||||
image = f.FIRMWARE_PATH.read_bytes()
|
||||
f.validate_image(image) # raises if the shipped blob is corrupt
|
||||
assert f.image_product(image).startswith("custom ")
|
||||
assert f.image_product(image).endswith("-CLEAN")
|
||||
assert f.bundled_version() == f.image_product(image)
|
||||
|
||||
|
||||
def test_validate_rejects_corruption():
|
||||
body = b"custom deadbeef-CLEAN" + bytes(64)
|
||||
f.validate_image(_wrap(body)) # the good case
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(b"\x00" * 4) # too short
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, magic=0x00)) # bad magic
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, checksum=0x00))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, crc=b"\x00\x00\x00\x00"))
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image(_wrap(body, body_len=len(body) + 1)) # length disagrees
|
||||
with pytest.raises(ValueError):
|
||||
f.validate_image((f.MAX_CODE_SIZE + 1).to_bytes(4, "little") + bytes(16))
|
||||
|
||||
|
||||
def test_image_product_requires_a_version_string():
|
||||
with pytest.raises(ValueError):
|
||||
f.image_product(b"no version here")
|
||||
|
||||
|
||||
def test_saved_config_preserves_the_first_backup(tmp_path):
|
||||
# the config page is per-unit; a reflash must rewrite the ORIGINAL, never the
|
||||
# bytes read back from a half-written dock
|
||||
p = str(tmp_path / "dock.bin")
|
||||
original = bytes(range(256))
|
||||
assert f.saved_config(p, original) == original
|
||||
# later flash reads something different -> the stored original wins
|
||||
assert f.saved_config(p, bytes(256)) == original
|
||||
with open(p, "rb") as fh:
|
||||
assert fh.read() == original
|
||||
|
||||
|
||||
def test_saved_config_rejects_wrong_size_backup(tmp_path):
|
||||
p = str(tmp_path / "dock.bin")
|
||||
with open(p, "wb") as fh:
|
||||
fh.write(b"\x00" * 8)
|
||||
with pytest.raises(RuntimeError):
|
||||
f.saved_config(p, bytes(256))
|
||||
|
||||
|
||||
def test_needs_flash_only_for_a_dock_on_wrong_firmware():
|
||||
expected = f.bundled_version()
|
||||
vid, pid = (int(x, 16) for x in f.VID_PIDS[0])
|
||||
rom_vid, rom_pid = (int(x, 16) for x in f.ROM_VID_PIDS[0])
|
||||
|
||||
assert not f.dock_needs_flash([])
|
||||
assert not f.dock_needs_flash([{"vendorId": 0x1234, "productId": 0x5678, "product": "something else"}])
|
||||
assert not f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": expected}])
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
# a ROM-mode board always needs flashing
|
||||
assert f.dock_needs_flash([{"vendorId": rom_vid, "productId": rom_pid, "product": f.ROM_PRODUCT}])
|
||||
|
||||
|
||||
def test_both_shipped_ids_trigger_the_check():
|
||||
for pair in f.VID_PIDS:
|
||||
vid, pid = (int(x, 16) for x in pair)
|
||||
assert f.dock_needs_flash([{"vendorId": vid, "productId": pid, "product": "custom 00000000-CLEAN"}])
|
||||
|
||||
|
||||
def test_rom_detection():
|
||||
assert f.in_rom_bootloader(f.ROM_VID_PIDS[0], "anything")
|
||||
assert f.in_rom_bootloader(("add1", "0001"), f.ROM_PRODUCT)
|
||||
assert f.in_rom_bootloader(("add1", "0001"), "AS2462something")
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), f.bundled_version())
|
||||
assert not f.in_rom_bootloader(("add1", "0001"), None)
|
||||
|
||||
|
||||
def test_config_paths_are_per_host_and_have_a_legacy_fallback():
|
||||
host = os.uname().nodename
|
||||
assert f.config_path().endswith(f"{host}.bin")
|
||||
assert f.config_path().startswith(f.CONFIG_DIR)
|
||||
# a dock flashed on this device by stock openpilot left its backup elsewhere
|
||||
assert f.legacy_config_path().startswith(f.LEGACY_CONFIG_DIR)
|
||||
assert f.legacy_config_path() != f.config_path()
|
||||
|
||||
|
||||
def test_we_do_not_autoflash():
|
||||
# upstream flashes from hardwared automatically; ours must stay deliberate
|
||||
# until it has been validated against a real dock
|
||||
import subprocess
|
||||
root = os.path.join(os.path.dirname(f.__file__), "..", "..", "..")
|
||||
hits = subprocess.run(["grep", "-rnI", "--exclude-dir=__pycache__", "flash_dock", os.path.join(root, "system"),
|
||||
os.path.join(root, "iqpilot")], capture_output=True, text=True).stdout
|
||||
callers = [ln for ln in hits.splitlines() if "egpu_dock/flash.py" not in ln and "test_" not in ln]
|
||||
assert callers == [], f"unexpected automatic flash caller: {callers}"
|
||||
|
||||
|
||||
def test_runtime_fw_gate_is_pinned_to_the_bundled_firmware():
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT
|
||||
assert EGPU_DOCK_FW_PRODUCT == f.bundled_version()
|
||||
|
||||
|
||||
def test_register_reads_default_to_superspeed_size():
|
||||
assert f.MAX_REGISTER_READ_SIZE == 255
|
||||
assert f.Flash().max_register_read_size == f.MAX_REGISTER_READ_SIZE
|
||||
|
||||
|
||||
def test_link_up_is_false_without_a_dock():
|
||||
assert f.link_up() is False
|
||||
41
iqpilot/system/hardware/tests/test_fan_controller.py
Normal file
41
iqpilot/system/hardware/tests/test_fan_controller.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.system.hardware.fan_controller import FanController
|
||||
|
||||
|
||||
class TestFanController:
|
||||
def test_ramp_anchors(self):
|
||||
c = FanController()
|
||||
assert c.update(60, True) == 0
|
||||
assert c.update(70, True) == 0
|
||||
assert c.update(85, True) == 80
|
||||
assert c.update(90, True) == 100
|
||||
assert c.update(100, True) == 100
|
||||
|
||||
def test_ramp_is_monotonic_and_continuous(self):
|
||||
c = FanController()
|
||||
temps = np.arange(50.0, 105.0, 0.25)
|
||||
outs = [c.update(t, True) for t in temps]
|
||||
assert all(b >= a for a, b in zip(outs, outs[1:]))
|
||||
# no step may exceed the steepest segment's slope (4 %/deg) over a 0.25 deg move
|
||||
assert max(b - a for a, b in zip(outs, outs[1:])) <= 2
|
||||
|
||||
def test_hot_onroad(self):
|
||||
assert FanController().update(100, True) >= 70
|
||||
|
||||
def test_offroad_capped(self):
|
||||
c = FanController()
|
||||
for t in (60, 75, 85, 100):
|
||||
assert c.update(t, False) <= 30
|
||||
|
||||
def test_no_fan_wear(self):
|
||||
assert FanController().update(10, False) == 0
|
||||
|
||||
def test_max_cool(self):
|
||||
c = FanController()
|
||||
assert c.update(80, True, True) == 100
|
||||
assert c.update(80, False, True) == 100
|
||||
|
||||
def test_target_band_has_airflow(self):
|
||||
# the design centers on 75 C; the curve must actually move air there
|
||||
assert 20 <= FanController().update(75, True) <= 40
|
||||
75
iqpilot/system/hardware/tests/test_hardwared.py
Normal file
75
iqpilot/system/hardware/tests/test_hardwared.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from iqpilot.system.hardware.hardwared import (
|
||||
ALLOWED_TICI_BRANCHES,
|
||||
CAN_STARTUP_RECOVERY_COOLDOWN,
|
||||
CAN_STARTUP_RECOVERY_DELAY,
|
||||
CAN_STARTUP_RECOVERY_MAX_ATTEMPTS,
|
||||
CanStartupRecovery,
|
||||
is_supported_tici_branch,
|
||||
)
|
||||
|
||||
|
||||
def test_beta_pq_allowed_for_tici():
|
||||
metadata = SimpleNamespace(channel="beta-pq", channel_type="dev")
|
||||
assert "beta-pq" in ALLOWED_TICI_BRANCHES
|
||||
assert is_supported_tici_branch(metadata)
|
||||
|
||||
|
||||
def test_tici_channel_type_allowed():
|
||||
metadata = SimpleNamespace(channel="random-branch", channel_type="tici")
|
||||
assert is_supported_tici_branch(metadata)
|
||||
|
||||
|
||||
def test_unsupported_branch_rejected_for_tici():
|
||||
metadata = SimpleNamespace(channel="random-branch", channel_type="dev")
|
||||
assert not is_supported_tici_branch(metadata)
|
||||
|
||||
|
||||
def recovery_update(recovery: CanStartupRecovery, now: float, **kwargs) -> bool:
|
||||
defaults = {
|
||||
"ignition": True,
|
||||
"started": True,
|
||||
"engaged": False,
|
||||
"car_state_alive": True,
|
||||
"can_timeout": True,
|
||||
"v_ego": 0.,
|
||||
}
|
||||
return recovery.update(now, **(defaults | kwargs))
|
||||
|
||||
|
||||
def test_can_startup_recovery_requires_persistent_timeout():
|
||||
recovery = CanStartupRecovery()
|
||||
assert not recovery_update(recovery, 10.)
|
||||
assert not recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY - 0.1)
|
||||
assert recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY)
|
||||
|
||||
|
||||
def test_can_startup_recovery_only_when_safe():
|
||||
for unsafe_state in (
|
||||
{"started": False},
|
||||
{"engaged": True},
|
||||
{"car_state_alive": False},
|
||||
{"can_timeout": False},
|
||||
{"v_ego": 0.2},
|
||||
):
|
||||
recovery = CanStartupRecovery()
|
||||
assert not recovery_update(recovery, 10., **unsafe_state)
|
||||
assert not recovery_update(recovery, 10. + CAN_STARTUP_RECOVERY_DELAY, **unsafe_state)
|
||||
|
||||
|
||||
def test_can_startup_recovery_is_bounded_and_resets_next_ignition():
|
||||
recovery = CanStartupRecovery()
|
||||
now = 10.
|
||||
for _ in range(CAN_STARTUP_RECOVERY_MAX_ATTEMPTS):
|
||||
assert not recovery_update(recovery, now)
|
||||
now += CAN_STARTUP_RECOVERY_DELAY
|
||||
assert recovery_update(recovery, now)
|
||||
now += CAN_STARTUP_RECOVERY_COOLDOWN
|
||||
|
||||
assert not recovery_update(recovery, now)
|
||||
assert not recovery_update(recovery, now + CAN_STARTUP_RECOVERY_DELAY)
|
||||
|
||||
assert not recovery_update(recovery, now + 10., ignition=False)
|
||||
assert not recovery_update(recovery, now + 11.)
|
||||
assert recovery_update(recovery, now + 11. + CAN_STARTUP_RECOVERY_DELAY)
|
||||
323
iqpilot/system/hardware/tests/test_power_monitoring.py
Normal file
323
iqpilot/system/hardware/tests/test_power_monitoring.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import pytest
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \
|
||||
CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S, \
|
||||
VBATT_HARD_SHUTDOWN, LOW_POWER_ENTRY_TIME_S
|
||||
|
||||
# Create fake time
|
||||
ssb = 0.
|
||||
def mock_time_monotonic():
|
||||
global ssb
|
||||
ssb += 1.
|
||||
return ssb
|
||||
|
||||
TEST_DURATION_S = 50
|
||||
GOOD_VOLTAGE = 12 * 1e3
|
||||
VOLTAGE_BELOW_PAUSE_CHARGING = (VBATT_PAUSE_CHARGING - 1) * 1e3
|
||||
|
||||
def pm_patch(mocker, name, value, constant=False):
|
||||
if constant:
|
||||
mocker.patch(f"iqpilot.system.hardware.power_monitoring.{name}", value)
|
||||
else:
|
||||
mocker.patch(f"iqpilot.system.hardware.power_monitoring.{name}", return_value=value)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_time(mocker):
|
||||
mocker.patch("time.monotonic", mock_time_monotonic)
|
||||
|
||||
|
||||
class TestPowerMonitoring:
|
||||
def setup_method(self):
|
||||
self.params = Params()
|
||||
|
||||
# Test to see that it doesn't do anything when pandaState is None
|
||||
def test_panda_state_present(self):
|
||||
pm = PowerMonitoring()
|
||||
for _ in range(10):
|
||||
pm.calculate(None, None)
|
||||
assert pm.get_power_used() == 0
|
||||
assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10)
|
||||
|
||||
# Test to see that it doesn't integrate offroad when ignition is True
|
||||
def test_offroad_ignition(self):
|
||||
pm = PowerMonitoring()
|
||||
for _ in range(10):
|
||||
pm.calculate(GOOD_VOLTAGE, True)
|
||||
assert pm.get_power_used() == 0
|
||||
|
||||
# Test to see that it integrates with discharging battery
|
||||
def test_offroad_integration_discharging(self, mocker):
|
||||
POWER_DRAW = 4
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
for _ in range(TEST_DURATION_S + 1):
|
||||
pm.calculate(GOOD_VOLTAGE, False)
|
||||
expected_power_usage = ((TEST_DURATION_S/3600) * POWER_DRAW * 1e6)
|
||||
assert abs(pm.get_power_used() - expected_power_usage) < 10
|
||||
|
||||
# Test to check positive integration of car_battery_capacity
|
||||
def test_car_battery_integration_onroad(self, mocker):
|
||||
POWER_DRAW = 4
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = 0
|
||||
for _ in range(TEST_DURATION_S + 1):
|
||||
pm.calculate(GOOD_VOLTAGE, True)
|
||||
expected_capacity = ((TEST_DURATION_S/3600) * CAR_CHARGING_RATE_W * 1e6)
|
||||
assert abs(pm.get_car_battery_capacity() - expected_capacity) < 10
|
||||
|
||||
# Test to check positive integration upper limit
|
||||
def test_car_battery_integration_upper_limit(self, mocker):
|
||||
POWER_DRAW = 4
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh - 1000
|
||||
for _ in range(TEST_DURATION_S + 1):
|
||||
pm.calculate(GOOD_VOLTAGE, True)
|
||||
estimated_capacity = CAR_BATTERY_CAPACITY_uWh + (CAR_CHARGING_RATE_W / 3600 * 1e6)
|
||||
assert abs(pm.get_car_battery_capacity() - estimated_capacity) < 10
|
||||
|
||||
# Test to check negative integration of car_battery_capacity
|
||||
def test_car_battery_integration_offroad(self, mocker):
|
||||
POWER_DRAW = 4
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
for _ in range(TEST_DURATION_S + 1):
|
||||
pm.calculate(GOOD_VOLTAGE, False)
|
||||
expected_capacity = CAR_BATTERY_CAPACITY_uWh - ((TEST_DURATION_S/3600) * POWER_DRAW * 1e6)
|
||||
assert abs(pm.get_car_battery_capacity() - expected_capacity) < 10
|
||||
|
||||
# Test to check negative integration lower limit
|
||||
def test_car_battery_integration_lower_limit(self, mocker):
|
||||
POWER_DRAW = 4
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = 1000
|
||||
for _ in range(TEST_DURATION_S + 1):
|
||||
pm.calculate(GOOD_VOLTAGE, False)
|
||||
estimated_capacity = 0 - ((1/3600) * POWER_DRAW * 1e6)
|
||||
assert abs(pm.get_car_battery_capacity() - estimated_capacity) < 10
|
||||
|
||||
# Test to check policy of stopping charging after MAX_TIME_OFFROAD_S
|
||||
def test_max_time_offroad(self, mocker):
|
||||
MOCKED_MAX_OFFROAD_TIME = 3600
|
||||
POWER_DRAW = 0 # To stop shutting down for other reasons
|
||||
pm_patch(mocker, "MAX_TIME_OFFROAD_S", MOCKED_MAX_OFFROAD_TIME, constant=True)
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
start_time = ssb
|
||||
ignition = False
|
||||
while ssb <= start_time + MOCKED_MAX_OFFROAD_TIME:
|
||||
pm.calculate(GOOD_VOLTAGE, ignition)
|
||||
if (ssb - start_time) % 1000 == 0 and ssb < start_time + MOCKED_MAX_OFFROAD_TIME:
|
||||
assert not pm.should_shutdown(ignition, True, start_time, False)
|
||||
assert pm.should_shutdown(ignition, True, start_time, False)
|
||||
|
||||
def test_car_voltage(self, mocker):
|
||||
POWER_DRAW = 0 # To stop shutting down for other reasons
|
||||
TEST_TIME = 350
|
||||
VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S = 50
|
||||
pm_patch(mocker, "VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S", VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S, constant=True)
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
ignition = False
|
||||
start_time = ssb
|
||||
for i in range(TEST_TIME):
|
||||
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
|
||||
if i % 10 == 0:
|
||||
assert pm.should_shutdown(ignition, True, start_time, True) == \
|
||||
(pm.car_voltage_mV < VBATT_PAUSE_CHARGING * 1e3 and \
|
||||
(ssb - start_time) > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S and \
|
||||
(ssb - start_time) > DELAY_SHUTDOWN_TIME_S)
|
||||
assert pm.should_shutdown(ignition, True, start_time, True)
|
||||
|
||||
# Test to check policy of not stopping charging when DisablePowerDown is set
|
||||
def test_disable_power_down(self, mocker):
|
||||
POWER_DRAW = 0 # To stop shutting down for other reasons
|
||||
TEST_TIME = 100
|
||||
self.params.put_bool("DisablePowerDown", True)
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
ignition = False
|
||||
for i in range(TEST_TIME):
|
||||
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
|
||||
if i % 10 == 0:
|
||||
assert not pm.should_shutdown(ignition, True, ssb, False)
|
||||
assert not pm.should_shutdown(ignition, True, ssb, False)
|
||||
|
||||
# Test to check policy of not stopping charging when ignition
|
||||
def test_ignition(self, mocker):
|
||||
POWER_DRAW = 0 # To stop shutting down for other reasons
|
||||
TEST_TIME = 100
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
ignition = True
|
||||
for i in range(TEST_TIME):
|
||||
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
|
||||
if i % 10 == 0:
|
||||
assert not pm.should_shutdown(ignition, True, ssb, False)
|
||||
assert not pm.should_shutdown(ignition, True, ssb, False)
|
||||
|
||||
# Test to check policy of not stopping charging when harness is not connected
|
||||
def test_harness_connection(self, mocker):
|
||||
POWER_DRAW = 0 # To stop shutting down for other reasons
|
||||
TEST_TIME = 100
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", POWER_DRAW)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
|
||||
ignition = False
|
||||
for i in range(TEST_TIME):
|
||||
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
|
||||
if i % 10 == 0:
|
||||
assert not pm.should_shutdown(ignition, False, ssb, False)
|
||||
assert not pm.should_shutdown(ignition, False, ssb, False)
|
||||
|
||||
def test_delay_shutdown_time(self):
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = 0
|
||||
ignition = False
|
||||
in_car = True
|
||||
offroad_timestamp = ssb
|
||||
started_seen = True
|
||||
pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition)
|
||||
|
||||
while ssb < offroad_timestamp + DELAY_SHUTDOWN_TIME_S:
|
||||
assert not pm.should_shutdown(ignition, in_car,
|
||||
offroad_timestamp,
|
||||
started_seen), \
|
||||
f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time"
|
||||
assert pm.should_shutdown(ignition, in_car,
|
||||
offroad_timestamp,
|
||||
started_seen), \
|
||||
f"Should shutdown after {DELAY_SHUTDOWN_TIME_S} seconds offroad time"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_time_offroad, offroad_time_min, expected_result",
|
||||
[
|
||||
# No max time set – fallback to default (30 hours)
|
||||
(None, 0, False),
|
||||
(None, MAX_TIME_OFFROAD_S + 1, True), # exceeds 30h (1800+ mins)
|
||||
|
||||
# Valid max time values (in minutes)
|
||||
(60, 59, False), # under limit
|
||||
(60, 120, True), # over limit
|
||||
(10, 8, False), # under limit
|
||||
(10, 11, True), # over limit
|
||||
|
||||
# Edge case: max time is zero → no limit enforced
|
||||
(0, 0, False),
|
||||
(0, 400, False),
|
||||
|
||||
# Invalid max time formats or negative values → fallback to 30 hours
|
||||
(-100, 100, False), # should fallback to 30h
|
||||
(-1, MAX_TIME_OFFROAD_S + 1, True), # should fallback to 30h, and exceed it
|
||||
]
|
||||
)
|
||||
def test_max_time_offroad_exceeded(self, max_time_offroad, offroad_time_min, expected_result):
|
||||
# Set the parameter if provided
|
||||
if max_time_offroad is not None:
|
||||
self.params.put("MaxTimeOffroad", max_time_offroad)
|
||||
|
||||
# Convert offroad time from minutes to seconds
|
||||
offroad_time_s = offroad_time_min * 60
|
||||
|
||||
pm = PowerMonitoring()
|
||||
result = pm.max_time_offroad_exceeded(offroad_time_s)
|
||||
|
||||
assert result == expected_result
|
||||
|
||||
# FastSleep must not shut down on the empty bookkeeping model while voltage is healthy
|
||||
def test_fast_sleep_ignores_battery_capacity_model(self, mocker):
|
||||
self.params.put_bool("FastSleep", True)
|
||||
self.params.put("MaxTimeOffroad", 0)
|
||||
try:
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = 0
|
||||
start_time = ssb
|
||||
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
|
||||
pm.calculate(GOOD_VOLTAGE, False)
|
||||
assert not pm.should_shutdown(False, True, start_time, True)
|
||||
finally:
|
||||
self.params.put_bool("FastSleep", False)
|
||||
|
||||
# FastSleep still shuts down below the hard voltage floor
|
||||
def test_fast_sleep_hard_voltage_floor(self, mocker):
|
||||
self.params.put_bool("FastSleep", True)
|
||||
try:
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
|
||||
pm = PowerMonitoring()
|
||||
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
|
||||
start_time = ssb
|
||||
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
|
||||
pm.calculate((VBATT_HARD_SHUTDOWN - 0.5) * 1e3, False)
|
||||
assert pm.should_shutdown(False, True, start_time, True)
|
||||
finally:
|
||||
self.params.put_bool("FastSleep", False)
|
||||
|
||||
def test_fast_sleep_low_power_entry(self, mocker):
|
||||
self.params.put_bool("FastSleep", True)
|
||||
try:
|
||||
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
|
||||
|
||||
# parked with the screen idled off: time-based entry at healthy voltage
|
||||
pm = PowerMonitoring()
|
||||
start_time = ssb
|
||||
for _ in range(LOW_POWER_ENTRY_TIME_S + 10):
|
||||
pm.calculate(GOOD_VOLTAGE, False)
|
||||
assert pm.should_enter_low_power(False, True, start_time, screen_off=True)
|
||||
assert not pm.should_enter_low_power(False, True, start_time, screen_off=False)
|
||||
assert not pm.should_enter_low_power(True, True, start_time, screen_off=True)
|
||||
assert not pm.should_enter_low_power(False, False, start_time, screen_off=True)
|
||||
|
||||
# sagging battery: voltage entry regardless of screen state
|
||||
pm = PowerMonitoring()
|
||||
start_time = ssb
|
||||
for _ in range(100):
|
||||
pm.calculate((VBATT_HARD_SHUTDOWN + 0.1) * 1e3, False)
|
||||
assert pm.should_enter_low_power(False, True, start_time, screen_off=False)
|
||||
|
||||
self.params.put_bool("FastSleep", False)
|
||||
assert not pm.should_enter_low_power(False, True, start_time, screen_off=True)
|
||||
finally:
|
||||
self.params.put_bool("FastSleep", False)
|
||||
|
||||
def test_negative_charging_interval_is_rejected(self, mocker):
|
||||
exception = mocker.patch("iqpilot.system.hardware.power_monitoring.cloudlog.exception")
|
||||
pm = PowerMonitoring()
|
||||
pm.last_measurement_time = ssb + 100
|
||||
capacity = pm.car_battery_capacity_uWh
|
||||
pm.calculate(GOOD_VOLTAGE, True)
|
||||
assert pm.car_battery_capacity_uWh == capacity
|
||||
exception.assert_called_once_with("Power monitoring calculation failed")
|
||||
|
||||
def test_negative_discharge_interval_is_rejected(self, mocker):
|
||||
exception = mocker.patch("iqpilot.system.hardware.power_monitoring.cloudlog.exception")
|
||||
pm = PowerMonitoring()
|
||||
pm.last_measurement_time = ssb + 100
|
||||
capacity = pm.car_battery_capacity_uWh
|
||||
pm._perform_integration(ssb, 4.0)
|
||||
assert pm.car_battery_capacity_uWh == capacity
|
||||
assert pm.power_used_uWh == 0
|
||||
exception.assert_called_once_with("Integration failed")
|
||||
|
||||
def test_max_time_offroad_uses_default_when_params_fail(self):
|
||||
class UnavailableParams:
|
||||
def get(self, key):
|
||||
raise RuntimeError(key)
|
||||
|
||||
pm = PowerMonitoring()
|
||||
pm.params = UnavailableParams()
|
||||
assert not pm.max_time_offroad_exceeded(MAX_TIME_OFFROAD_S - 1)
|
||||
assert pm.max_time_offroad_exceeded(MAX_TIME_OFFROAD_S)
|
||||
|
||||
def test_shutdown_requires_offroad_timestamp(self):
|
||||
assert not PowerMonitoring().should_shutdown(False, True, None, True)
|
||||
279
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
279
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
deviceState.usbState carries a per-device USB bus snapshot plus the ssusb
|
||||
link-error counter (IQ.OS 4.9.1+ `portli`) into every rlog. Drive it off a
|
||||
synthetic sysfs tree so parsing, eGPU dock presence and the link-error
|
||||
plumbing are pinned without hardware.
|
||||
"""
|
||||
from iqpilot.cereal import messaging
|
||||
from iqpilot.system.hardware.usb import (
|
||||
EGPU_DOCK_FW_PRODUCT, EGPU_DOCK_ROM_USB_IDS, EGPU_DOCK_USB_IDS, controller, egpu_dock_present,
|
||||
egpu_dock_ready, get_link_error_count,
|
||||
get_usb_topology, get_usb_state, link_controller, read_hex_counter, set_usb_state, usb3_lane,
|
||||
)
|
||||
|
||||
|
||||
def _mkctrl(root, name="a800000.ssusb", portli="0x00000000"):
|
||||
"""Platform controller dir, mirroring /sys/devices/platform/soc/<x>.ssusb."""
|
||||
ctrl = root / "soc" / name
|
||||
ctrl.mkdir(parents=True)
|
||||
if portli is not None:
|
||||
(ctrl / "portli").write_text(portli + "\n")
|
||||
return ctrl
|
||||
|
||||
|
||||
def _mkdev(root, name, *, vid, pid, busnum=1, devnum=2, speed=5000,
|
||||
manufacturer="ACME", product="Widget", ctrl=None):
|
||||
"""USB device under the controller, symlinked into the bus view like sysfs."""
|
||||
real = (ctrl / "usb1" / name) if ctrl is not None else (root / "bus" / name)
|
||||
real.mkdir(parents=True)
|
||||
(real / "idVendor").write_text(f"{vid:04x}\n")
|
||||
(real / "idProduct").write_text(f"{pid:04x}\n")
|
||||
(real / "busnum").write_text(f"{busnum}\n")
|
||||
(real / "devnum").write_text(f"{devnum}\n")
|
||||
(real / "speed").write_text(f"{speed}\n")
|
||||
(real / "manufacturer").write_text(manufacturer + "\n")
|
||||
(real / "product").write_text(product + "\n")
|
||||
|
||||
bus = root / "bus"
|
||||
bus.mkdir(parents=True, exist_ok=True)
|
||||
link = bus / name
|
||||
if real != link:
|
||||
link.symlink_to(real)
|
||||
return real
|
||||
|
||||
|
||||
def test_missing_sysfs_is_empty(tmp_path):
|
||||
assert get_usb_state(tmp_path / "nope") == []
|
||||
|
||||
|
||||
def test_entries_without_idvendor_are_skipped(tmp_path):
|
||||
(tmp_path / "bus" / "usb1").mkdir(parents=True) # a root hub dir with no idVendor
|
||||
_mkdev(tmp_path, "1-2", vid=0x1234, pid=0x5678)
|
||||
state = get_usb_state(tmp_path / "bus")
|
||||
assert len(state) == 1 and state[0]["vendorId"] == 0x1234
|
||||
|
||||
|
||||
def test_fields_parsed_with_hex_ids(tmp_path):
|
||||
_mkdev(tmp_path, "1-2", vid=0x0BDA, pid=0x8153, busnum=3, devnum=7,
|
||||
speed=480, manufacturer="Realtek", product="USB 10/100 LAN")
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev == {
|
||||
"busnum": 3, "devnum": 7,
|
||||
"vendorId": 0x0BDA, "productId": 0x8153,
|
||||
"speedMbps": 480,
|
||||
"manufacturer": "Realtek", "product": "USB 10/100 LAN",
|
||||
"linkErrorCount": 0, # no controller in this device's path
|
||||
"usb3Lane": "unknown", # not on the type-C port's controller
|
||||
}
|
||||
|
||||
|
||||
def test_unreadable_strings_default_empty(tmp_path):
|
||||
real = _mkdev(tmp_path, "1-2", vid=0x1, pid=0x2)
|
||||
(real / "manufacturer").unlink()
|
||||
(real / "product").unlink()
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["manufacturer"] == "" and dev["product"] == ""
|
||||
|
||||
|
||||
def test_hex_counter_parsing(tmp_path):
|
||||
f = tmp_path / "portli"
|
||||
f.write_text("0x00000000\n")
|
||||
assert read_hex_counter(f) == 0
|
||||
f.write_text("0x0000002a\n")
|
||||
assert read_hex_counter(f) == 42
|
||||
f.write_text("0000002a\n") # bare hex, no 0x prefix
|
||||
assert read_hex_counter(f) == 42
|
||||
f.write_text("garbage\n")
|
||||
assert read_hex_counter(f) == 0
|
||||
assert read_hex_counter(tmp_path / "absent") == 0 # pre-4.9.1 IQ.OS
|
||||
|
||||
|
||||
def test_controller_resolved_from_device(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path)
|
||||
_mkdev(tmp_path, "1-2", vid=0x1, pid=0x2, ctrl=ctrl)
|
||||
assert controller(tmp_path / "bus" / "1-2") == ctrl.resolve()
|
||||
|
||||
|
||||
def test_device_carries_its_controllers_link_errors(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path, portli="0x0000000c")
|
||||
_mkdev(tmp_path, "1-2", vid=0x1234, pid=0x5678, ctrl=ctrl)
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["linkErrorCount"] == 12
|
||||
|
||||
|
||||
def test_controller_count_without_any_enumerated_device(tmp_path):
|
||||
# peripheral mode (eMac gadget link): the peer never enumerates on our side,
|
||||
# so the counter must still be readable off the controller
|
||||
_mkctrl(tmp_path, portli="0x00000005")
|
||||
assert get_usb_state(tmp_path / "bus") == []
|
||||
assert get_link_error_count(tmp_path / "soc") == 5
|
||||
|
||||
|
||||
def test_link_errors_summed_across_controllers(tmp_path):
|
||||
_mkctrl(tmp_path, name="a800000.ssusb", portli="0x00000002")
|
||||
_mkctrl(tmp_path, name="a600000.ssusb", portli="0x00000003")
|
||||
assert get_link_error_count(tmp_path / "soc") == 5
|
||||
|
||||
|
||||
def test_missing_portli_reads_zero(tmp_path):
|
||||
_mkctrl(tmp_path, portli=None) # pre-4.9.1 kernel: file absent
|
||||
assert get_link_error_count(tmp_path / "soc") == 0
|
||||
|
||||
|
||||
def test_set_usb_state_populates_message_and_flags_dock(tmp_path):
|
||||
ctrl = _mkctrl(tmp_path, portli="0x00000007")
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678, speed=480, ctrl=ctrl)
|
||||
_mkdev(tmp_path, "1-2", vid=EGPU_DOCK_USB_IDS[0][0], pid=EGPU_DOCK_USB_IDS[0][1], speed=5000, ctrl=ctrl)
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"), get_link_error_count(tmp_path / "soc"))
|
||||
|
||||
devices = list(msg.deviceState.usbState.devices)
|
||||
assert len(devices) == 2
|
||||
assert {d.speedMbps for d in devices} == {480, 5000}
|
||||
assert all(d.linkErrorCount == 7 for d in devices)
|
||||
assert msg.deviceState.usbState.linkErrorCount == 7
|
||||
assert msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_dock_absent_when_not_plugged(tmp_path):
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678)
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"))
|
||||
assert not msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_both_shipped_dock_usb_ids_detected(tmp_path):
|
||||
# comma ships the dock under two VID/PIDs; only the first was known before
|
||||
for i, (vid, pid) in enumerate(EGPU_DOCK_USB_IDS):
|
||||
root = tmp_path / f"v{i}"
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid)
|
||||
assert egpu_dock_present(root / "bus"), f"{vid:#06x}:{pid:#06x} not detected"
|
||||
|
||||
|
||||
def test_dock_in_rom_mode_is_not_present(tmp_path):
|
||||
# bootloader/ROM state enumerates but cannot serve a GPU until flashed
|
||||
vid, pid = EGPU_DOCK_ROM_USB_IDS[0]
|
||||
_mkdev(tmp_path, "1-1", vid=vid, pid=pid)
|
||||
assert not egpu_dock_present(tmp_path / "bus")
|
||||
|
||||
|
||||
def test_empty_bus_clears_flag():
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, [])
|
||||
assert len(msg.deviceState.usbState.devices) == 0
|
||||
assert msg.deviceState.usbState.linkErrorCount == 0
|
||||
assert not msg.deviceState.egpuDockPresent
|
||||
|
||||
|
||||
def test_link_error_count_masked_to_16_bits(tmp_path):
|
||||
# the per-device field is UInt16 upstream; a wrapped counter must not overflow it
|
||||
ctrl = _mkctrl(tmp_path, portli="0x0001ffff")
|
||||
_mkdev(tmp_path, "1-2", vid=0x1, pid=0x2, ctrl=ctrl)
|
||||
(dev,) = get_usb_state(tmp_path / "bus")
|
||||
assert dev["linkErrorCount"] == 0xFFFF
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, get_usb_state(tmp_path / "bus"))
|
||||
assert list(msg.deviceState.usbState.devices)[0].linkErrorCount == 0xFFFF
|
||||
|
||||
|
||||
def test_usb_topology_lists_bus_entries(tmp_path):
|
||||
_mkdev(tmp_path, "1-1", vid=0x1, pid=0x2)
|
||||
_mkdev(tmp_path, "1-2", vid=0x3, pid=0x4)
|
||||
assert {"1-1", "1-2"} <= get_usb_topology(tmp_path / "bus")
|
||||
assert get_usb_topology(tmp_path / "nope") == set()
|
||||
|
||||
|
||||
def _mkudc(root, name="a600000.dwc3"):
|
||||
udc = root / "udc" / name
|
||||
udc.mkdir(parents=True, exist_ok=True)
|
||||
(udc / "state").write_text("not attached\n")
|
||||
return root / "udc"
|
||||
|
||||
|
||||
def test_link_controller_derived_from_udc_not_hardcoded(tmp_path):
|
||||
# comma pins "a600000.ssusb"; we derive it, so another board still resolves
|
||||
assert link_controller(_mkudc(tmp_path)) == "a600000.ssusb"
|
||||
assert link_controller(_mkudc(tmp_path / "other", "a800000.dwc3")) == "a800000.ssusb"
|
||||
|
||||
|
||||
def test_link_controller_absent_udc_is_empty(tmp_path):
|
||||
assert link_controller(tmp_path / "nope") == ""
|
||||
|
||||
|
||||
def test_usb3_lane_mapping():
|
||||
assert usb3_lane(1) == "a"
|
||||
assert usb3_lane(2) == "b"
|
||||
assert usb3_lane(0) == "unknown" # unattached
|
||||
assert usb3_lane(None if False else 7) == "unknown"
|
||||
|
||||
|
||||
def test_port_lane_survives_gadget_mode(tmp_path):
|
||||
"""The case upstream cannot report: in peripheral mode nothing enumerates on
|
||||
the link controller, so every Device row is 'unknown' while the eMac link is
|
||||
up. The port-level field still carries it."""
|
||||
ctrl = _mkctrl(tmp_path, name="a800000.ssusb")
|
||||
_mkdev(tmp_path, "1-1", vid=0x1234, pid=0x5678, ctrl=ctrl) # panda, host controller
|
||||
_mkudc(tmp_path) # gadget is a600000
|
||||
|
||||
devices = get_usb_state(tmp_path / "bus", tmp_path / "udc")
|
||||
assert all(d["usb3Lane"] == "unknown" for d in devices), "no device sits on the gadget controller"
|
||||
|
||||
msg = messaging.new_message('deviceState')
|
||||
set_usb_state(msg.deviceState, devices, 0, lane="b")
|
||||
assert msg.deviceState.usbState.usb3Lane == "b"
|
||||
assert all(d.usb3Lane == "unknown" for d in msg.deviceState.usbState.devices)
|
||||
|
||||
|
||||
def test_device_on_link_controller_gets_the_lane(tmp_path):
|
||||
# host mode on the type-C port (eGPU dock): upstream's per-device field populates
|
||||
ctrl = _mkctrl(tmp_path, name="a600000.ssusb")
|
||||
_mkdev(tmp_path, "1-1", vid=EGPU_DOCK_USB_IDS[0][0], pid=EGPU_DOCK_USB_IDS[0][1], ctrl=ctrl)
|
||||
_mkudc(tmp_path)
|
||||
import iqpilot.system.hardware.usb as usbmod
|
||||
orig = usbmod.usb3_lane
|
||||
usbmod.usb3_lane = lambda orientation=None: "a"
|
||||
try:
|
||||
devices = get_usb_state(tmp_path / "bus", tmp_path / "udc")
|
||||
finally:
|
||||
usbmod.usb3_lane = orig
|
||||
assert devices[0]["usb3Lane"] == "a"
|
||||
|
||||
|
||||
def test_dock_ready_requires_the_bundled_firmware_product(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product=EGPU_DOCK_FW_PRODUCT)
|
||||
assert egpu_dock_present(root / "bus")
|
||||
assert egpu_dock_ready(root / "bus")
|
||||
|
||||
|
||||
def test_dock_on_foreign_firmware_is_present_but_not_ready(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product="custom deadbeef-CLEAN")
|
||||
assert egpu_dock_present(root / "bus")
|
||||
assert not egpu_dock_ready(root / "bus")
|
||||
|
||||
|
||||
def test_rom_mode_dock_is_neither_present_nor_ready(tmp_path):
|
||||
root = tmp_path
|
||||
vid, pid = EGPU_DOCK_ROM_USB_IDS[0]
|
||||
_mkdev(root, "1-1", vid=vid, pid=pid, product="USB 3.2 PCIe TinyEnclosure")
|
||||
assert not egpu_dock_present(root / "bus")
|
||||
assert not egpu_dock_ready(root / "bus")
|
||||
|
||||
|
||||
class TestEnsureHostRole:
|
||||
def test_already_host_needs_no_write(self, tmp_path):
|
||||
from iqpilot.system.hardware.usb import ensure_host_role
|
||||
mode = tmp_path / "mode"
|
||||
mode.write_text("host\n")
|
||||
assert ensure_host_role(mode)
|
||||
|
||||
def test_missing_controller_is_false(self, tmp_path):
|
||||
from iqpilot.system.hardware.usb import ensure_host_role
|
||||
assert not ensure_host_role(tmp_path / "absent" / "mode")
|
||||
Reference in New Issue
Block a user