From 11cefcb26672f5d0f04e9ed10c72b1a14e641a7d Mon Sep 17 00:00:00 2001 From: "IQ.Lvbs CI [bot]" Date: Sat, 22 Aug 2026 10:38:43 -0500 Subject: [PATCH] IQ.Pilot Release Commit @ 4521b0f --- .../iqpilot_updater_private/manifest.json | 16 +- .../iqpilot_updater_private/manifest.json.sig | 2 +- ..._pat_blob.cpython-312-aarch64-linux-gnu.so | Bin 0 -> 67616 bytes ..._pat_blob.cpython-312-aarch64-linux-gnu.so | Bin 67616 -> 67616 bytes ...it_remote.cpython-312-aarch64-linux-gnu.so | Bin 70056 -> 136488 bytes .../iqdbc/car/volkswagen/carcontroller.py | 14 +- .../iqdbc/iqdbc/car/volkswagen/mebcan.py | 27 ++- .../iqdbc/iqdbc/car/volkswagen/pqcan.py | 3 +- .../iqdbc/lvbs/car/tesla/test_torque_blend.py | 205 ++++++++++++++++++ .../iqdbc/lvbs/car/tesla/torque_blend.py | 42 +++- .../panda/board/drivers/can_common.h | 28 +++ iqpilot/common/steer_delay.py | 29 ++- iqpilot/common/tests/test_steer_delay.py | 91 ++++++++ iqpilot/selfdrive/controls/controlsd.py | 12 +- .../selfdrive/controls/iq_controls_layer.py | 3 +- iqpilot/selfdrive/iqmodeld/daemon.py | 4 +- .../models/runners/tinygrad/fused_runner.py | 26 ++- .../tests/test_fused_runner_guards.py | 128 +++++++++++ .../iqmodeld/tests/test_lat_delay_source.py | 72 ++++++ iqpilot/selfdrive/locationd/torqued.py | 4 +- iqpilot/system/hardware/tici/agnos.json | 30 ++- iqpilot/system/hardware/tici/agnos.json.sig | 2 +- iqpilot/system/hardware/tici/agnos.py | 104 ++++++++- .../system/hardware/tici/agnos_tici_15_1.json | 28 +-- .../hardware/tici/agnos_tici_15_1.json.sig | 2 +- .../hardware/tici/tests/test_agnos_updater.py | 42 +++- iqpilot/system/manager/manager.py | 4 + iqpilot/system/manager/process_config.py | 2 +- iqpilot/system/ui/lib/os_update.py | 13 +- 29 files changed, 822 insertions(+), 111 deletions(-) create mode 100755 artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so create mode 100644 artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/test_torque_blend.py create mode 100644 iqpilot/common/tests/test_steer_delay.py create mode 100644 iqpilot/selfdrive/iqmodeld/tests/test_fused_runner_guards.py create mode 100644 iqpilot/selfdrive/iqmodeld/tests/test_lat_delay_source.py diff --git a/artifacts/iqpilot_updater_private/manifest.json b/artifacts/iqpilot_updater_private/manifest.json index c8386eb92..e8f50441f 100644 --- a/artifacts/iqpilot_updater_private/manifest.json +++ b/artifacts/iqpilot_updater_private/manifest.json @@ -14,15 +14,20 @@ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 0 }, + "python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "94664d1b9abd1c0cb124707bf10f746f23adf0847a890d507df86f1e4a7e8ca9", + "size": 67616 + }, "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f8c5eeeb8cfb6fb077ab80e95a93bb8a36c819a2d2147e560ec015cfe43eed20", + "sha256": "481235a5685e14f5b8e9216908be23be9743fe6f487f2b38fb21c33dd84e22cf", "size": 67616 }, "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f9806ad70ffe7718e64a3ecb23821b2aee91658562a525767675d944035ce235", - "size": 70056 + "sha256": "75ffbf8212f805b054c97ca4723a56071db822e889cda9d6bec233b2d2c8592a", + "size": 136488 }, "runtime": { "entries": { @@ -33,7 +38,8 @@ } }, "signatures": { - "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": "7RM6ZvkT7i7s3djWFUPOs3s/kOiscys7+o+3gGTLlPC6PJAyalRGOPkn5SlsnfaLZk8CIaENZEpqfspISa+DBw==", - "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": "pSvniR0EAnpTwpKqGvM8deAR8qe68W40n/pjkAO8QqNuz0/UgKQFQWlms5E+Xvz6yTMhg33W4ICOSu65mT4MCA==" + "python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so": "B45oDHpHNmslzjTyeuEVbE4fOytUsC/0po+CiHiLI+T01yWJIb6Dt4yFJm+qVTEqCLJfajedd1FnIQHCyUddDw==", + "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": "a+YcE2ik7Oo3btunKGoA+FHN+uSDoric2V6nu5SjtKSM/Nwpg1M9BX0BM55sA56sz6FgPRn93u+yPL7eryLYDw==", + "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": "NcdigDJOQ7d9A+HXCewPowezSML1i4cHW6uRyZ1N6vmtkXDorRk9YC4lxlM8uvoS/tB5/jQBkfc/DhkwPXAoAA==" } } diff --git a/artifacts/iqpilot_updater_private/manifest.json.sig b/artifacts/iqpilot_updater_private/manifest.json.sig index 5c5bc3a1d..d17c8b251 100644 --- a/artifacts/iqpilot_updater_private/manifest.json.sig +++ b/artifacts/iqpilot_updater_private/manifest.json.sig @@ -1 +1 @@ -8plcIUBv95Bl+JlOV98JKmC9l9njuGkxueNgvoEeQ0HcGVmTDiCYIvq72XhFsmwdFuOkGCDzn0st3TWu7D1vBg== +54xdruMwhqg4ouUav9CwbOhcsytYbn/uy/OL1pT6Sd/JRvgsLckfe8+jsG527nyhZ7sBpxPx5dFLX3h/eOaSDg== diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000000000000000000000000000000000000..acc884ee10682053ba52dd07590914ff16ee3546 GIT binary patch literal 67616 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 literal 0 HcmV?d00001 diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so index 0213d3326e6be40fd2bbfaf3bfe78e4d4ab52a4b..12129a3a5cbed3190d47109074e2b44561896a98 100755 GIT binary patch delta 39 vcmZ25fn~u2mJNPt62=xOX=$lR7G~xqDHet%=B9?p7M4k7Mw`>rc0B|D{$C9C delta 39 vcmZ25fn~u2mJNPt5@r_3mKK&K=4pvZrpZPYrb(&E<|!sdNt@Huc0B|D{a6iB diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so index d87f636cb37ecea5536a9401ec8f1cb4293e644f..7f44ac5b7cc6bf16bf1e931de8a905da5066f124 100755 GIT binary patch literal 136488 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

ikhV+VWA!5BpUOrB4N2a@eh>S#XH+@*Qzi|}r; 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 delta 25868 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�sz#{P?1@jNPF#pM=|m2fCxX!A*sUWCTs=$6(7 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 0: + acc_hold_type = ACC_HMS_RAMP_RELEASE + ramp_counter -= 1 + else: + pass + + return acc_hold_type, ramp_counter def create_acc_accel_control(packer, bus, CP, acc_type, acc_enabled, upper_jerk, lower_jerk, upper_control_limit, lower_control_limit, diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/pqcan.py b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/pqcan.py index 56b9b3627..43942b00f 100644 --- a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/pqcan.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/pqcan.py @@ -139,7 +139,7 @@ def create_blinker_control(packer, bus, leftBlinker, rightBlinker): def create_acc_hud_control(packer, bus, acc_hud_status, set_speed, leadDistance, distanceBars, fcw_alert, leadVisible, unavailable, decel, d_unresponsive): - priodisp = 0 if fcw_alert else 1 if (acc_hud_status == 4 or decel or leadVisible) else 2 if (acc_hud_status in (3, 2)) else 0 + priodisp = 0 if fcw_alert else 0 if (acc_hud_status == 4 or decel) else 2 if (acc_hud_status in (3, 2) or leadVisible) else 0 leadDistanceBars = distanceBars + 1 if distanceBars in (1, 2, 3) else 2 values = { "ACA_StaACC": acc_hud_status, @@ -149,7 +149,6 @@ def create_acc_hud_control(packer, bus, acc_hud_status, set_speed, leadDistance, "ACA_gemZeitl": min(15, max(1, int(round(leadDistance)))) if leadVisible else 0, "ACA_PrioDisp": priodisp, "ACA_Akustik1": d_unresponsive, - # "ACA_Fahrerhinw": unavailable, "ACA_Akustik2": fcw_alert, "ACA_ACC_Verz": decel, } diff --git a/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/test_torque_blend.py b/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/test_torque_blend.py new file mode 100644 index 000000000..530507c85 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/test_torque_blend.py @@ -0,0 +1,205 @@ +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos +""" +from types import SimpleNamespace + +import pytest + +from iqdbc.car.tesla.interface import CarInterface +from iqdbc.car.vehicle_model import VehicleModel +from iqdbc.lvbs.car.tesla.torque_blend import ( + DT_LAT_CTRL, + STEER_OVERRIDE_MAX_LAT_ACCEL, + STEER_OVERRIDE_MIN_TORQUE, + STEER_OVERRIDE_TORQUE_RANGE, + STEER_OVERRIDE_TORQUE_ZERO_MAX, + SteeringTorqueZero, + TorqueBlendController, + calc_override_angle_limited, + get_steer_from_lat_accel, +) +from iqdbc.lvbs.car.tesla.values import TeslaFlagsIQ + +VM = VehicleModel(CarInterface.get_non_essential_params("TESLA_MODEL_Y")) + +COOP_ON = SimpleNamespace(flags=TeslaFlagsIQ.COOP_STEERING.value) +COOP_OFF = SimpleNamespace(flags=0) + +HAND_REST_TORQUE = 0.55 +ZERO_WEIGHT_SPEED = 20.0 +FULL_WEIGHT_SPEED = 8.0 +SETTLING_SECONDS = 300.0 + + +def _car_state(torque, v_ego, angle=0.0): + return SimpleNamespace( + out=SimpleNamespace(steeringTorque=torque, vEgo=v_ego, vEgoRaw=v_ego, steeringAngleDeg=angle, steeringRateDeg=0.0), + hands_on_level=0, + ) + + +def _drive(blend, torque, v_ego, seconds, angle=0.0, cp_iq=COOP_ON, lat_active=True): + steps = int(seconds / DT_LAT_CTRL) + out = 0.0 + for _ in range(steps): + out = blend.update(angle, lat_active, cp_iq, _car_state(torque, v_ego, angle), VM).steeringAngleDeg + return out + + +def _override(blend): + return blend.coop_apply_angle_last - blend.debug_angle_desired_limited + + +MAX_NULLABLE_PRELOAD = STEER_OVERRIDE_MIN_TORQUE + STEER_OVERRIDE_TORQUE_ZERO_MAX + + +@pytest.mark.parametrize("rest", [0.3, HAND_REST_TORQUE, MAX_NULLABLE_PRELOAD]) +@pytest.mark.parametrize("sign", [1.0, -1.0]) +def test_torque_zero_pulls_a_sustained_hand_rest_inside_the_deadzone(rest, sign): + zero = SteeringTorqueZero() + for _ in range(int(SETTLING_SECONDS / DT_LAT_CTRL)): + corrected = zero.update(sign * rest, True) + assert abs(corrected) <= STEER_OVERRIDE_MIN_TORQUE + + +@pytest.mark.parametrize("sign", [1.0, -1.0]) +def test_a_settled_hand_rest_at_the_nullable_limit_produces_no_override(sign): + blend = TorqueBlendController() + _drive(blend, sign * MAX_NULLABLE_PRELOAD, ZERO_WEIGHT_SPEED, seconds=SETTLING_SECONDS) + assert abs(_override(blend)) == pytest.approx(0.0, abs=1e-6) + + +def test_override_unwinds_once_the_driver_releases(): + blend = TorqueBlendController() + _drive(blend, 2.0, FULL_WEIGHT_SPEED, seconds=10.0) + assert abs(_override(blend)) > 1.0 + _drive(blend, 0.0, FULL_WEIGHT_SPEED, seconds=20.0) + assert _override(blend) == pytest.approx(0.0, abs=1e-6) + assert blend.override_angle_accu == pytest.approx(0.0, abs=1e-6) + + +def test_torque_zero_passes_a_step_through_untouched(): + zero = SteeringTorqueZero() + assert zero.update(2.0, True) == pytest.approx(2.0, abs=1e-3) + + +def test_torque_zero_is_bounded(): + zero = SteeringTorqueZero() + for _ in range(int(SETTLING_SECONDS / DT_LAT_CTRL)): + zero.update(10.0, True) + assert zero.update(0.0, False) == pytest.approx(-STEER_OVERRIDE_TORQUE_ZERO_MAX) + + +def test_torque_zero_holds_while_not_learning(): + zero = SteeringTorqueZero() + zero.update(1.0, False) + assert zero.update(1.0, False) == pytest.approx(1.0) + + +def test_sustained_hand_rest_stops_steering_the_car(): + blend = TorqueBlendController() + _drive(blend, HAND_REST_TORQUE, FULL_WEIGHT_SPEED, seconds=1.0) + early = abs(_override(blend)) + _drive(blend, HAND_REST_TORQUE, FULL_WEIGHT_SPEED, seconds=SETTLING_SECONDS) + assert early > 0.1 + assert abs(_override(blend)) < 0.1 * early + + +def test_a_deliberate_push_still_overrides_after_the_zero_has_settled(): + blend = TorqueBlendController() + _drive(blend, HAND_REST_TORQUE, FULL_WEIGHT_SPEED, seconds=SETTLING_SECONDS) + _drive(blend, HAND_REST_TORQUE + 1.5, FULL_WEIGHT_SPEED, seconds=1.0) + assert abs(_override(blend)) > 1.0 + + +def test_accumulator_cannot_charge_where_its_weight_is_zero(): + blend = TorqueBlendController() + _drive(blend, 2.0, ZERO_WEIGHT_SPEED, seconds=30.0) + assert blend.override_angle_accu == pytest.approx(0.0, abs=1e-6) + + +def test_accumulator_drains_once_the_car_passes_the_crossover_speed(): + blend = TorqueBlendController() + _drive(blend, 2.5, FULL_WEIGHT_SPEED, seconds=10.0) + assert abs(blend.override_angle_accu) > 1.0 + _drive(blend, 2.5, ZERO_WEIGHT_SPEED, seconds=5.0) + assert blend.override_angle_accu == pytest.approx(0.0, abs=1e-6) + + +def test_low_speed_override_can_exceed_the_direct_term_authority(): + blend = TorqueBlendController() + _drive(blend, 2.5, FULL_WEIGHT_SPEED, seconds=60.0) + authority = calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, FULL_WEIGHT_SPEED, VM, STEER_OVERRIDE_MAX_LAT_ACCEL) + assert abs(blend.override_angle_accu) > authority + + +def test_a_sustained_low_speed_push_reaches_a_large_override(): + blend = TorqueBlendController() + _drive(blend, 2.5, FULL_WEIGHT_SPEED, seconds=10.0) + assert abs(_override(blend)) > 20.0 + + +@pytest.mark.parametrize("v_ego", [3.0, 5.0, FULL_WEIGHT_SPEED, 12.0]) +def test_override_stays_inside_the_max_lat_accel_envelope_plus_the_direct_term(v_ego): + blend = TorqueBlendController() + _drive(blend, 2.5, v_ego, seconds=60.0) + envelope = get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, v_ego, VM) + direct_authority = calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, v_ego, VM, STEER_OVERRIDE_MAX_LAT_ACCEL) + assert abs(_override(blend)) <= envelope + direct_authority + + +@pytest.mark.parametrize("v_ego", [3.0, 5.0, FULL_WEIGHT_SPEED, 12.0]) +def test_a_pinned_push_cannot_run_the_contribution_away(v_ego): + blend = TorqueBlendController() + _drive(blend, 2.5, v_ego, seconds=130.0) + envelope = get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, v_ego, VM) + capability = calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, v_ego, VM, STEER_OVERRIDE_MAX_LAT_ACCEL) / envelope + assert abs(blend.override_angle_accu) * (1.0 - capability) <= envelope + 1e-6 + + +def test_the_envelope_opens_up_as_speed_drops(): + low = get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, 3.0, VM) + high = get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, 12.0, VM) + assert low > 10 * high + + +def test_relative_weight_is_zero_above_the_direct_capability_crossover(): + capability = (calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, ZERO_WEIGHT_SPEED, VM, STEER_OVERRIDE_MAX_LAT_ACCEL) / + get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, ZERO_WEIGHT_SPEED, VM)) + assert capability == pytest.approx(1.0) + + +HEAVY_REST = 0.80 + + +@pytest.mark.parametrize("sign", [1.0, -1.0]) +def test_the_zero_nulls_a_hand_rest_heavier_than_the_deadzone(sign): + assert HEAVY_REST > 2 * STEER_OVERRIDE_MIN_TORQUE + blend = TorqueBlendController() + _drive(blend, sign * HEAVY_REST, FULL_WEIGHT_SPEED, seconds=SETTLING_SECONDS) + assert abs(_override(blend)) == pytest.approx(0.0, abs=1e-6) + + +LIGHT_PUSH = 0.45 + + +@pytest.mark.parametrize("v_ego", [FULL_WEIGHT_SPEED, 17.0]) +def test_a_light_push_overrides_without_needing_the_old_half_newton(v_ego): + assert STEER_OVERRIDE_MIN_TORQUE < LIGHT_PUSH < 0.5 + blend = TorqueBlendController() + _drive(blend, LIGHT_PUSH, v_ego, seconds=5.0) + assert abs(_override(blend)) > 0.2 + + +def test_torque_below_the_deadzone_never_overrides(): + blend = TorqueBlendController() + _drive(blend, STEER_OVERRIDE_MIN_TORQUE * 0.5, FULL_WEIGHT_SPEED, seconds=5.0) + assert abs(_override(blend)) == pytest.approx(0.0, abs=1e-6) + + +def test_coop_disabled_leaves_the_command_untouched(): + blend = TorqueBlendController() + angle = 5.0 + out = _drive(blend, 2.0, FULL_WEIGHT_SPEED, seconds=5.0, angle=angle, cp_iq=COOP_OFF) + assert out == pytest.approx(angle, abs=1e-6) + assert blend.override_angle_accu == pytest.approx(0.0) diff --git a/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/torque_blend.py b/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/torque_blend.py index c8c40926a..866a798eb 100644 --- a/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/torque_blend.py +++ b/artifacts/package_sources/iqdbc/iqdbc/lvbs/car/tesla/torque_blend.py @@ -21,7 +21,7 @@ class TorqueBlendParams(CarControllerParams): STEERING_DEG_PHASE_LEAD_COEFF = 8.0 # angle override # todo implement steering torque inertia compensation to increase gains -STEER_OVERRIDE_MIN_TORQUE = 0.5 # Nm - based on typical steering bias + noise +STEER_OVERRIDE_MIN_TORQUE = 0.35 # Nm - only noise now, SteeringTorqueZero removes the bias this used to cover STEER_OVERRIDE_MAX_TORQUE = 2.5 # Nm max torque before EPS disengages, LKAS takes over at 1.8Nm STEER_OVERRIDE_MAX_LAT_ACCEL = 1.5 # m/s^2 - determines angle rate - speed dependent - similar to Tesla comfort steering mode STEER_OVERRIDE_LAT_ACCEL_GAIN_LIMIT = 10 # deg/Nm stability and smoothness for angle control # todo this could be increased after solving feedback stability @@ -33,6 +33,9 @@ STEER_OVERRIDE_MAX_LAT_JERK_CENTERING = TorqueBlendParams.ANGLE_LIMITS.MAX_LATER STEER_OVERRIDE_LAT_JERK_GAIN_LIMIT = 100 # deg/s/Nm - should be less than CarControllerParams.ANGLE_LIMITS.MAX_ANGLE_RATE / DT_CTRL / STEER_OVERRIDE_TORQUE_RANGE STEER_OVERRIDE_TORQUE_RANGE = STEER_OVERRIDE_MAX_TORQUE - STEER_OVERRIDE_MIN_TORQUE +STEER_OVERRIDE_TORQUE_ZERO_TAU = 60.0 +STEER_OVERRIDE_TORQUE_ZERO_MAX = 0.5 # Nm - sized to the observed hand rest and sensor bias, not to the deadzone + # model fighting mitigation STEER_DESIRED_LIMITER_ALLOW_SPEED = 6.0 # m/s - below this speed the desired angle limiter is active STEER_DESIRED_LIMITER_ACCEL = 100 # deg/s^2 when override angle ramp is active @@ -96,6 +99,20 @@ def calc_override_angle_delta_limited(torque: float, vEgo: float, VM: VehicleMod return apply_bounds(override_angle_rate * DT_LAT_CTRL, TorqueBlendParams.ANGLE_LIMITS.MAX_ANGLE_RATE) +class SteeringTorqueZero: + def __init__(self): + self._zero = 0.0 + + def reset(self) -> None: + self._zero = 0.0 + + def update(self, torque: float, learning: bool) -> float: + if learning: + self._zero += (torque - self._zero) * (DT_LAT_CTRL / STEER_OVERRIDE_TORQUE_ZERO_TAU) + self._zero = apply_bounds(self._zero, STEER_OVERRIDE_TORQUE_ZERO_MAX) + return torque - self._zero + + class SteerRateLimiter: """Handles rate limiting of steering angle changes with a configurable rate.""" def __init__(self): @@ -169,6 +186,7 @@ class TorqueBlendController: self.resume_rate_limiter_delta = SteerRateLimiter() self.resume_rate_limiter = SteerRateLimiter() self.override_accel_rate_limiter = SteerAccelLimiter() + self.torque_zero = SteeringTorqueZero() self.debug_angle_desired_limited = 0 def apply_override_angle_direct(self, lat_active: bool, driverTorque: float, vEgo: float, VM: VehicleModel) -> float: @@ -238,16 +256,25 @@ class TorqueBlendController: if not lat_active: return 0 + override_envelope = get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, vEgo, VM) # calculate capability of direct angle override (fully active above ~36kph) - direct_override_capability = (calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, vEgo, VM, STEER_OVERRIDE_MAX_LAT_ACCEL) / - get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, vEgo, VM)) + direct_override_capability = calc_override_angle_limited(STEER_OVERRIDE_TORQUE_RANGE, vEgo, VM, + STEER_OVERRIDE_MAX_LAT_ACCEL) / override_envelope angle_override_direct = self.apply_override_angle_direct(lat_active, driverTorque, vEgo, VM) relative_weight = 1.0 - direct_override_capability - angle_override_relative = self.apply_override_angle_relative(lat_active, driverTorque, vEgo, VM, - unwind_weight=relative_weight) + if relative_weight > 0.0: + self.apply_override_angle_relative(lat_active, driverTorque, vEgo, VM, unwind_weight=relative_weight) + self.override_angle_accu = apply_bounds(self.override_angle_accu, override_envelope / relative_weight) + else: + self.center_override_angle_accu(vEgo, VM) - return angle_override_direct * direct_override_capability + angle_override_relative * relative_weight + return angle_override_direct * direct_override_capability + self.override_angle_accu * relative_weight + + def center_override_angle_accu(self, vEgo: float, VM: VehicleModel) -> None: + centering_delta = calc_override_angle_delta_limited(STEER_OVERRIDE_TORQUE_RANGE, vEgo, VM, + STEER_OVERRIDE_MAX_LAT_JERK_CENTERING) + self.override_angle_accu -= apply_bounds(self.override_angle_accu, centering_delta) def overriding_steer_desired_accel_limit(self, lat_active: bool, apply_angle: float, vEgo: float, steeringTorque: float) -> float: """ @@ -302,7 +329,8 @@ class TorqueBlendController: # apply_angle = self.overriding_steer_desired_accel_limit(lat_active, apply_angle, CS.out.vEgo, CS.out.steeringTorque) self.debug_angle_desired_limited = apply_angle #! debug - apply_angle += self.apply_override_angle_combined(lat_active, CS.out.steeringTorque, CS.out.vEgo, VM) + driver_torque = self.torque_zero.update(CS.out.steeringTorque, lat_active) + apply_angle += self.apply_override_angle_combined(lat_active, driver_torque, CS.out.vEgo, VM) # final rate limit - matching panda safety self.coop_apply_angle_last = apply_angle diff --git a/artifacts/package_sources/panda/board/drivers/can_common.h b/artifacts/package_sources/panda/board/drivers/can_common.h index a9270fe86..a8b64c457 100644 --- a/artifacts/package_sources/panda/board/drivers/can_common.h +++ b/artifacts/package_sources/panda/board/drivers/can_common.h @@ -191,6 +191,10 @@ void ignition_can_hook(CANPacket_t *msg) { static int toyota_gear = TOYOTA_GEAR_P; static int toyota_hybrid_gear = TOYOTA_HYBRID_GEAR_P; static bool toyota_hybrid_gear_seen = false; + static bool vw_meb_seen = false; + static bool vw_meb_getriebe_out_of_p = false; + static bool vw_meb_gateway_out_of_p = false; + static int prev_counter_vw_meb = -1; // GM exception if ((msg->addr == 0x1F1U) && (len == 8)) { @@ -293,6 +297,30 @@ void ignition_can_hook(CANPacket_t *msg) { } } + if ((msg->addr == 0xADU) && (len == 8)) { + int fahrstufe = (msg->data[5] >> 2) & 0xFU; + vw_meb_getriebe_out_of_p = (fahrstufe >= 6) && (fahrstufe <= 14); + } + + if ((msg->addr == 0x3DCU) && (len == 8)) { + int fahrstufe = msg->data[5] & 0xFU; + vw_meb_gateway_out_of_p = (fahrstufe >= 6) && (fahrstufe <= 14); + } + + if ((msg->addr == 0x13DU) && (len == 32)) { + vw_meb_seen = true; + } + + if ((msg->addr == 0x3C0U) && (len == 4) && vw_meb_seen) { + int counter = msg->data[1] & 0xFU; + if ((counter == ((prev_counter_vw_meb + 1) % 16)) && (prev_counter_vw_meb != -1)) { + bool vw_meb_out_of_park = vw_meb_getriebe_out_of_p || vw_meb_gateway_out_of_p; + ignition_can = (((msg->data[2] >> 1) & 1U) != 0U) && vw_meb_out_of_park; + ignition_can_cnt = 0U; + ignition_can_gates_line = true; + } + prev_counter_vw_meb = counter; + } } } diff --git a/iqpilot/common/steer_delay.py b/iqpilot/common/steer_delay.py index 29b7e2f17..349f975d3 100644 --- a/iqpilot/common/steer_delay.py +++ b/iqpilot/common/steer_delay.py @@ -6,6 +6,7 @@ live estimator learned, or the driver's fixed software delay — gated by the "IQLiveSteerDelay" param. The pick is mirrored into "IQSteerDelayCache" so consumers that do not subscribe to lateralDelay can still read the current value. """ +from iqpilot.cereal import car from iqpilot.common.params import Params _ENABLE_KEY = "IQLiveSteerDelay" @@ -13,13 +14,32 @@ _FIXED_KEY = "IQSoftwareSteerDelay" _CACHE_KEY = "IQSteerDelayCache" +def fixed_steer_delay(params, stock_delay): + """The rack's own delay plus the driver's IQSoftwareSteerDelay offset, as the UI reports it.""" + return stock_delay + float(params.get(_FIXED_KEY, return_default=True)) + + def resolve_steer_delay(params, stock_delay): - """Learned lateral delay while live-learning is enabled, otherwise the stock delay.""" + """Learned lateral delay while live-learning is enabled, otherwise the driver's fixed delay.""" if not params.get_bool(_ENABLE_KEY): - return stock_delay + return fixed_steer_delay(params, stock_delay) return float(params.get(_CACHE_KEY, return_default=True)) +def lateral_action_delay(params, car_params, live_delay): + """Delay the lateral path should be planned against. + + Angle cars honour the IQLiveSteerDelay toggle so that with live learning off the + estimate never reaches the path: lagd cross-correlates against localizer lateral + accel, so it reports whole-vehicle response (~0.36 s measured on VW MQB, 0.44 s on + Tesla) where the lookahead wants actuator delay (~0.10 s). Torque cars keep the + live estimate. + """ + if car_params.steerControlType == car.CarParams.SteerControlType.angle: + return resolve_steer_delay(params, car_params.steerActuatorDelay) + return live_delay + + def cached_steer_delay(): """Last value SteerDelayPublisher mirrored into the param — usable without a lateralDelay subscription (e.g. at process startup).""" @@ -34,10 +54,7 @@ class SteerDelayPublisher: self._params = Params() self._actuator_delay = car_params.steerActuatorDelay - def _fixed_delay(self): - return self._actuator_delay + self._params.get(_FIXED_KEY, return_default=True) - def update(self, lag_msg): live = self._params.get_bool(_ENABLE_KEY) - value = lag_msg.lateralDelay.lateralDelay if live else self._fixed_delay() + value = lag_msg.lateralDelay.lateralDelay if live else fixed_steer_delay(self._params, self._actuator_delay) self._params.put_nonblocking(_CACHE_KEY, value) diff --git a/iqpilot/common/tests/test_steer_delay.py b/iqpilot/common/tests/test_steer_delay.py new file mode 100644 index 000000000..7050c3bcb --- /dev/null +++ b/iqpilot/common/tests/test_steer_delay.py @@ -0,0 +1,91 @@ +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos +""" +import time + +import pytest + +import iqpilot.cereal.messaging as messaging +from iqpilot.cereal import car +from iqpilot.common.params import Params +from iqpilot.common.steer_delay import ( + SteerDelayPublisher, + cached_steer_delay, + fixed_steer_delay, + lateral_action_delay, + resolve_steer_delay, +) + +ANGLE = car.CarParams.SteerControlType.angle +TORQUE = car.CarParams.SteerControlType.torque + +LIVE_DELAY = 0.4387 +RACK_DELAY = 0.10 +OFFSET = 0.05 + + +@pytest.fixture +def params(tmp_path, monkeypatch): + monkeypatch.setenv("PARAMS_ROOT", str(tmp_path)) + p = Params() + p.put("IQSteerDelayCache", LIVE_DELAY) + p.put("IQSoftwareSteerDelay", OFFSET) + return p + + +def _car_params(steer_control_type): + cp = car.CarParams.new_message() + cp.steerControlType = steer_control_type + cp.steerActuatorDelay = RACK_DELAY + return cp + + +def _lateral_delay_msg(value): + msg = messaging.new_message("lateralDelay") + msg.lateralDelay.lateralDelay = value + return msg.as_reader() + + +def test_params_fixture_is_isolated_from_the_real_device(params, tmp_path): + assert str(tmp_path) in params.get_param_path("") + + +@pytest.mark.parametrize("live_enabled", [True, False]) +def test_torque_cars_always_use_live_delay(params, live_enabled): + params.put_bool("IQLiveSteerDelay", live_enabled) + assert lateral_action_delay(params, _car_params(TORQUE), LIVE_DELAY) == pytest.approx(LIVE_DELAY) + + +def test_angle_cars_ignore_live_delay_when_self_tuning_is_off(params): + params.put_bool("IQLiveSteerDelay", False) + delay = lateral_action_delay(params, _car_params(ANGLE), LIVE_DELAY) + assert delay == pytest.approx(RACK_DELAY + OFFSET) + assert delay != pytest.approx(LIVE_DELAY) + + +def test_angle_cars_use_cached_delay_when_self_tuning_is_on(params): + params.put_bool("IQLiveSteerDelay", True) + assert lateral_action_delay(params, _car_params(ANGLE), LIVE_DELAY) == pytest.approx(LIVE_DELAY) + + +@pytest.mark.parametrize("offset", [0.05, 0.20, 0.50]) +def test_manual_offset_reaches_the_path_and_matches_what_the_ui_reports(params, offset): + params.put_bool("IQLiveSteerDelay", False) + params.put("IQSoftwareSteerDelay", offset) + ui_total = RACK_DELAY + offset + assert fixed_steer_delay(params, RACK_DELAY) == pytest.approx(ui_total) + assert lateral_action_delay(params, _car_params(ANGLE), LIVE_DELAY) == pytest.approx(ui_total) + + +@pytest.mark.parametrize("live_enabled", [False, True]) +def test_publisher_writes_the_value_the_resolver_reads(params, live_enabled): + params.put_bool("IQLiveSteerDelay", live_enabled) + params.put("IQSteerDelayCache", -1.0) + SteerDelayPublisher(_car_params(ANGLE)).update(_lateral_delay_msg(LIVE_DELAY)) + + expected = LIVE_DELAY if live_enabled else RACK_DELAY + OFFSET + deadline = time.monotonic() + 5.0 + while cached_steer_delay() != pytest.approx(expected) and time.monotonic() < deadline: + time.sleep(0.01) + assert cached_steer_delay() == pytest.approx(expected) + assert resolve_steer_delay(params, RACK_DELAY) == pytest.approx(expected) diff --git a/iqpilot/selfdrive/controls/controlsd.py b/iqpilot/selfdrive/controls/controlsd.py index 1eebe0067..bf6a83a6b 100755 --- a/iqpilot/selfdrive/controls/controlsd.py +++ b/iqpilot/selfdrive/controls/controlsd.py @@ -13,7 +13,7 @@ from iqpilot.common.swaglog import cloudlog from iqdbc.car.car_helpers import interfaces from iqdbc.car.vehicle_model import VehicleModel -from iqpilot.common.steer_delay import resolve_steer_delay +from iqpilot.common.steer_delay import lateral_action_delay from iqpilot.selfdrive.controls.lib.drive_helpers import clip_curvature from iqpilot.selfdrive.controls.lib.curvature_lookahead import get_lookahead_curvature from iqpilot.selfdrive.controls.lib.latcontrol import LatControl @@ -227,15 +227,7 @@ class Controls(IQControlsLayer): lat_accel_override = bool(CS.gasPressed) or bool(self.sm['iqState'].aol.active) self.desired_curvature, curvature_limited = clip_curvature(CS.vEgo, self.desired_curvature, new_desired_curvature, lp.roll, lat_accel_override) - # ALC (angle control) only: honour IQLiveSteerDelay so that with live learning off, lagd's - # estimate never reaches the controls loop and CP.steerActuatorDelay is used instead. lagd - # cross-correlates against localizer lateral accel, so it reports whole-vehicle response - # (~0.36 s measured on VW MQB) where the lookahead wants actuator delay (~0.10 s). - # Torque cars keep their existing path. - if self.CP.steerControlType == car.CarParams.SteerControlType.angle: - lat_delay = resolve_steer_delay(self.params, self.CP.steerActuatorDelay) + LAT_SMOOTH_SECONDS - else: - lat_delay = self.sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS + lat_delay = lateral_action_delay(self.params, self.CP, self.sm["lateralDelay"].lateralDelay) + LAT_SMOOTH_SECONDS lookahead_curvature = None if not self.sm.valid['lateralManeuverPlan']: lookahead_curvature = get_lookahead_curvature(model_v2, CS.vEgo, lat_delay) diff --git a/iqpilot/selfdrive/controls/iq_controls_layer.py b/iqpilot/selfdrive/controls/iq_controls_layer.py index 3c19a0050..f9064b2bf 100644 --- a/iqpilot/selfdrive/controls/iq_controls_layer.py +++ b/iqpilot/selfdrive/controls/iq_controls_layer.py @@ -14,7 +14,6 @@ from iqdbc.car import structs from iqpilot.common.constants import CV from iqpilot.common.params import Params from iqpilot.common.swaglog import cloudlog -from iqpilot.common.steer_delay import resolve_steer_delay from iqpilot.selfdrive.car.enhanced_stock_longitudinal_control import build_iq_control_params_from_plan from iqpilot.selfdrive.iqmodeld.models.inference_state import InferenceStateBase from iqpilot.selfdrive.controls.lib.helpers.blinker_pause import IQSignalPauseController @@ -60,7 +59,7 @@ class IQControlsLayer(InferenceStateBase): return self.blinker_pause_lateral.get_params() if self.CP.lateralTuning.which() == 'torque': - self.lat_delay = resolve_steer_delay(self.params, sm["lateralDelay"].lateralDelay) + self.lat_delay = sm["lateralDelay"].lateralDelay self._sync_set_speed = self._want_set_speed_to_limit() self.radar_manager.read_params() self._next_param_refresh = now diff --git a/iqpilot/selfdrive/iqmodeld/daemon.py b/iqpilot/selfdrive/iqmodeld/daemon.py index 683b0471a..23bdfb7e2 100755 --- a/iqpilot/selfdrive/iqmodeld/daemon.py +++ b/iqpilot/selfdrive/iqmodeld/daemon.py @@ -32,7 +32,7 @@ from iqpilot.selfdrive.controls.lib.drive_helpers import ( from iqpilot.selfdrive.locationd.calibration_helpers import get_calibrated_rpy from iqpilot.system import sentry -from iqpilot.common.steer_delay import resolve_steer_delay +from iqpilot.common.steer_delay import lateral_action_delay from iqpilot.selfdrive.iqmodeld.models.helpers import get_active_bundle from iqpilot.selfdrive.iqmodeld.models.inference_state import InferenceStateBase from iqpilot.selfdrive.iqmodeld.models.runners.model_runner import get_model_runner @@ -510,7 +510,7 @@ class InferenceDaemon: def _refresh_tunables(self, tick: int) -> None: if tick % 60 != 0: return - self._runtime.lat_delay = resolve_steer_delay(self._params, self._sub["lateralDelay"].lateralDelay) + self._runtime.lat_delay = lateral_action_delay(self._params, self._car_params, self._sub["lateralDelay"].lateralDelay) self._runtime.PLANPLUS_CONTROL = self._params.get("PlanplusControl", return_default=True) self._runtime.model_smoothing_max_extra_sec = _model_lat_smooth_max_sec(self._params) self._warps.set_offset(self._params.get("CameraOffset", return_default=True)) diff --git a/iqpilot/selfdrive/iqmodeld/models/runners/tinygrad/fused_runner.py b/iqpilot/selfdrive/iqmodeld/models/runners/tinygrad/fused_runner.py index ffa21ac8b..736fd5f7a 100644 --- a/iqpilot/selfdrive/iqmodeld/models/runners/tinygrad/fused_runner.py +++ b/iqpilot/selfdrive/iqmodeld/models/runners/tinygrad/fused_runner.py @@ -112,13 +112,26 @@ class TinygradFusedRunner(ModelRunner): } # shapes must match the captured run_policy JIT inputs on_shapes = self._on_meta['input_shapes'] + captured = self._run_policy.captured + jit_shapes = { + name: tuple(int(s) for s in view.shape) + for name, (view, _vars, _dtype, _device) in zip(captured.expected_names, captured.expected_input_info) + } + + def policy_input_shape(name): + shape = on_shapes.get(name, jit_shapes.get(name)) + if shape is None: + raise ValueError(f"fused pkl declares no shape for policy input {name}") + return shape + self._npy_buffers = { 'desire': np.zeros(dp[2], dtype=np.float32), - 'traffic_convention': np.zeros(on_shapes['traffic_convention'], dtype=np.float32), - 'action_t': np.zeros(on_shapes['action_t'], dtype=np.float32), + 'traffic_convention': np.zeros(policy_input_shape('traffic_convention'), dtype=np.float32), 'tfm': np.zeros((3, 3), dtype=np.float32), 'big_tfm': np.zeros((3, 3), dtype=np.float32), } + if 'action_t' in jit_shapes: + self._npy_buffers['action_t'] = np.zeros(policy_input_shape('action_t'), dtype=np.float32) self._cam_resolution = (cam_w, cam_h) def run_fused(self, bufs: dict, transforms: dict[str, np.ndarray], numpy_inputs: NumpyDict) -> NumpyDict: @@ -134,7 +147,7 @@ class TinygradFusedRunner(ModelRunner): self._npy_buffers['desire'][:] = numpy_inputs[desire_key] if 'traffic_convention' in numpy_inputs: self._npy_buffers['traffic_convention'][:] = numpy_inputs['traffic_convention'] - if 'action_t' in numpy_inputs: + if 'action_t' in numpy_inputs and 'action_t' in self._npy_buffers: self._npy_buffers['action_t'][:] = numpy_inputs['action_t'] self._npy_buffers['tfm'][:] = transforms['img'] self._npy_buffers['big_tfm'][:] = transforms['big_img'] @@ -149,9 +162,12 @@ class TinygradFusedRunner(ModelRunner): img, big_img = warp_jit(img_q=self._queues['img_q'], big_img_q=self._queues['big_img_q'], tfm=npy('tfm'), big_tfm=npy('big_tfm'), frame=frame, big_frame=big_frame) - vision_out_t, on_out_t, off_out_t = self._run_policy( + policy_inputs = dict( img=img, big_img=big_img, feat_q=self._queues['feat_q'], desire_q=self._queues['desire_q'], - desire=npy('desire'), traffic_convention=npy('traffic_convention'), action_t=npy('action_t')) + desire=npy('desire'), traffic_convention=npy('traffic_convention')) + if 'action_t' in self._npy_buffers: + policy_inputs['action_t'] = npy('action_t') + vision_out_t, on_out_t, off_out_t = self._run_policy(**policy_inputs) # parse each model's output on its own sliced dict; parsing a merged dict # would run parse_dynamic_outputs twice and double-parse plan/lead diff --git a/iqpilot/selfdrive/iqmodeld/tests/test_fused_runner_guards.py b/iqpilot/selfdrive/iqmodeld/tests/test_fused_runner_guards.py new file mode 100644 index 000000000..a15fcb47d --- /dev/null +++ b/iqpilot/selfdrive/iqmodeld/tests/test_fused_runner_guards.py @@ -0,0 +1,128 @@ +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +from __future__ import annotations + +import pickle +from dataclasses import dataclass + +import numpy as np +import pytest + +from iqpilot.selfdrive.iqmodeld.models.runners import model_runner as model_runner_mod +from iqpilot.selfdrive.iqmodeld.models.runners.model_runner import ModelType +from iqpilot.selfdrive.iqmodeld.models.runners.tinygrad import fused_runner as fused_mod + + +class _View: + def __init__(self, shape): + self.shape = shape + + +class _Captured: + def __init__(self, expected_names, expected_input_info): + self.expected_names = expected_names + self.expected_input_info = expected_input_info + + +class _FakeJit: + def __init__(self, expected_names, expected_input_info): + self.captured = _Captured(expected_names, expected_input_info) + + def __call__(self, **kwargs): + raise AssertionError("policy jit should not run in this test") + + +class _FakeTensor: + def __init__(self, arr, device=None): + self.shape = tuple(np.asarray(arr).shape) + + def contiguous(self): + return self + + def realize(self): + return self + + +class _FakeDevice: + DEFAULT = "FAKE" + + +@dataclass +class _Type: + raw: int + + +@dataclass +class _Artifact: + fileName: str + + +class _Model: + def __init__(self, file_name): + self.type = _Type(ModelType.vision) + self.artifact = _Artifact(file_name) + self.metadata = None + + +class _Bundle: + def __init__(self, file_name): + self.models = [_Model(file_name)] + self.is20hz = True + + +POLICY_INPUTS = ["action_t", "big_img", "desire", "desire_q", "feat_q", "img", "traffic_convention"] +POLICY_SHAPES = { + "action_t": (1, 2), "big_img": (1, 12, 128, 256), "desire": (1, 8), "desire_q": (1, 100, 8), + "feat_q": (1, 99, 512), "img": (1, 12, 128, 256), "traffic_convention": (1, 2), +} + + +def _write_fused_pkl(path, policy_inputs): + info = [(_View(POLICY_SHAPES[n]), (), None, "NPY") for n in policy_inputs] + role_meta = { + "input_shapes": {"desire_pulse": (1, 100, 8), "traffic_convention": (1, 2), "features_buffer": (1, 99, 512)}, + "output_slices": {}, + } + blob = { + "metadata": { + "vision": {"input_shapes": {"img": (1, 12, 128, 256), "big_img": (1, 12, 128, 256)}, "output_slices": {}}, + "on_policy": role_meta, + "off_policy": role_meta, + }, + "run_policy": _FakeJit(policy_inputs, info), + "frame_skip": 4, + (1928, 1208): _FakeJit(["frame"], [(_View((1,)), (), None, "NPY")]), + } + with open(path, "wb") as f: + pickle.dump(blob, f) + + +@pytest.fixture +def fused_runner(tmp_path, monkeypatch): + def _build(policy_inputs): + name = "driving_fused_test.pkl" + _write_fused_pkl(tmp_path / name, policy_inputs) + monkeypatch.setattr(model_runner_mod, "_fetch_bundle", lambda params=None: _Bundle(name)) + monkeypatch.setattr(fused_mod, "CUSTOM_MODEL_PATH", str(tmp_path)) + monkeypatch.setattr(fused_mod, "_tinygrad_imports", lambda: (_FakeTensor, _FakeDevice)) + return fused_mod.TinygradFusedRunner() + return _build + + +def test_action_t_allocated_when_only_the_jit_declares_it(fused_runner): + runner = fused_runner(POLICY_INPUTS) + assert "action_t" not in runner._on_meta["input_shapes"] + + runner._ensure_queues(1928, 1208) + + assert runner._npy_buffers["action_t"].shape == POLICY_SHAPES["action_t"] + assert runner._npy_buffers["traffic_convention"].shape == POLICY_SHAPES["traffic_convention"] + + +def test_action_t_absent_when_the_jit_does_not_take_it(fused_runner): + runner = fused_runner([n for n in POLICY_INPUTS if n != "action_t"]) + + runner._ensure_queues(1928, 1208) + + assert "action_t" not in runner._npy_buffers diff --git a/iqpilot/selfdrive/iqmodeld/tests/test_lat_delay_source.py b/iqpilot/selfdrive/iqmodeld/tests/test_lat_delay_source.py new file mode 100644 index 000000000..bd4fcba79 --- /dev/null +++ b/iqpilot/selfdrive/iqmodeld/tests/test_lat_delay_source.py @@ -0,0 +1,72 @@ +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos +""" +from types import SimpleNamespace + +import pytest + +from iqpilot.cereal import car +from iqpilot.common.params import Params +from iqpilot.selfdrive.iqmodeld.daemon import InferenceDaemon + +LIVE_DELAY = 0.4387 +RACK_DELAY = 0.10 +OFFSET = 0.05 + + +@pytest.fixture +def params(tmp_path, monkeypatch): + monkeypatch.setenv("PARAMS_ROOT", str(tmp_path)) + p = Params() + p.put("IQSteerDelayCache", LIVE_DELAY) + p.put("IQSoftwareSteerDelay", OFFSET) + p.put_bool("ModelSmoothingEnabled", False) + p.put("ModelLatSmoothSec", 0) + p.put("PlanplusControl", 1.0) + p.put("CameraOffset", 0.0) + return p + + +def _daemon(params, steer_control_type): + car_params = car.CarParams.new_message() + car_params.steerControlType = steer_control_type + car_params.steerActuatorDelay = RACK_DELAY + return SimpleNamespace( + _params=params, + _car_params=car_params, + _sub={"lateralDelay": SimpleNamespace(lateralDelay=LIVE_DELAY)}, + _runtime=SimpleNamespace(lat_delay=None, PLANPLUS_CONTROL=None, model_smoothing_max_extra_sec=None), + _warps=SimpleNamespace(set_offset=lambda _: None), + ) + + +@pytest.mark.parametrize("live_enabled, expected", [(False, RACK_DELAY + OFFSET), (True, LIVE_DELAY)]) +def test_angle_cars_honour_the_self_tuning_toggle(params, live_enabled, expected): + params.put_bool("IQLiveSteerDelay", live_enabled) + daemon = _daemon(params, car.CarParams.SteerControlType.angle) + InferenceDaemon._refresh_tunables(daemon, 0) + assert daemon._runtime.lat_delay == pytest.approx(expected) + + +def test_angle_cars_never_plan_against_the_live_estimate_when_disabled(params): + params.put_bool("IQLiveSteerDelay", False) + daemon = _daemon(params, car.CarParams.SteerControlType.angle) + InferenceDaemon._refresh_tunables(daemon, 0) + assert daemon._runtime.lat_delay != pytest.approx(LIVE_DELAY) + + +@pytest.mark.parametrize("live_enabled", [True, False]) +def test_torque_cars_keep_the_live_estimate(params, live_enabled): + params.put_bool("IQLiveSteerDelay", live_enabled) + daemon = _daemon(params, car.CarParams.SteerControlType.torque) + InferenceDaemon._refresh_tunables(daemon, 0) + assert daemon._runtime.lat_delay == pytest.approx(LIVE_DELAY) + + +def test_refresh_is_throttled_to_every_sixtieth_tick(params): + params.put_bool("IQLiveSteerDelay", False) + daemon = _daemon(params, car.CarParams.SteerControlType.angle) + InferenceDaemon._refresh_tunables(daemon, 1) + assert daemon._runtime.lat_delay is None + InferenceDaemon._refresh_tunables(daemon, 60) + assert daemon._runtime.lat_delay == pytest.approx(RACK_DELAY + OFFSET) diff --git a/iqpilot/selfdrive/locationd/torqued.py b/iqpilot/selfdrive/locationd/torqued.py index b311f6884..eab08828d 100755 --- a/iqpilot/selfdrive/locationd/torqued.py +++ b/iqpilot/selfdrive/locationd/torqued.py @@ -10,7 +10,6 @@ from iqpilot.common.realtime import DT_MDL from iqpilot.common.filter_simple import FirstOrderFilter from iqpilot.common.swaglog import cloudlog from iqpilot.selfdrive.locationd.helpers import PointBuckets, ParameterEstimator, PoseCalibrator, Pose -from iqpilot.common.steer_delay import resolve_steer_delay HISTORY = 5 # secs POINTS_PER_BUCKET = 1500 @@ -97,7 +96,6 @@ class TorqueEstimator(ParameterEstimator): # try to restore cached params params = Params() - self.params = params params_cache = params.get("CarParamsPrevRoute") torque_cache = params.get("LiveTorqueParameters") if params_cache is not None and torque_cache is not None: @@ -179,7 +177,7 @@ class TorqueEstimator(ParameterEstimator): elif which == "extrinsicsCalibration": self.calibrator.feed_live_calib(msg) elif which == "lateralDelay": - self.lag = resolve_steer_delay(self.params, msg.lateralDelay) + self.lag = msg.lateralDelay # calculate lateral accel from past steering torque elif which == "deviceMotion": if len(self.raw_points['steer_torque']) == self.hist_len: diff --git a/iqpilot/system/hardware/tici/agnos.json b/iqpilot/system/hardware/tici/agnos.json index 45693c856..bd8aba746 100644 --- a/iqpilot/system/hardware/tici/agnos.json +++ b/iqpilot/system/hardware/tici/agnos.json @@ -1,7 +1,7 @@ [ { "name": "xbl", - "url": "https://commadist.azureedge.net/agnosupdate/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz", "hash": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6", "hash_raw": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6", "size": 3282256, @@ -12,7 +12,7 @@ }, { "name": "xbl_config", - "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz", "hash": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9", "hash_raw": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9", "size": 98124, @@ -23,7 +23,7 @@ }, { "name": "abl", - "url": "https://commadist.azureedge.net/agnosupdate/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz", "hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", "hash_raw": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", "size": 274432, @@ -34,7 +34,7 @@ }, { "name": "aop", - "url": "https://commadist.azureedge.net/agnosupdate/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz", "hash": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788", "hash_raw": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788", "size": 184364, @@ -45,7 +45,7 @@ }, { "name": "devcfg", - "url": "https://commadist.azureedge.net/agnosupdate/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz", "hash": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585", "hash_raw": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585", "size": 40336, @@ -56,7 +56,7 @@ }, { "name": "splash", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/splash-993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/splash-993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2.img.xz", "hash": "993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2", "hash_raw": "993d7fb8ddfa552bd7f60e8a78b8735efbc716a0978682ed3c92fa0d694528d2", "size": 34226176, @@ -67,7 +67,7 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/boot-aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb.img.xz", "hash": "aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb", "hash_raw": "aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb", "size": 18216960, @@ -78,18 +78,14 @@ }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b.img.xz", - "hash": "a7bec67c4fef85c66736e74ee8828cbef320f3729f72fee6bea4c97f23dc8b70", - "hash_raw": "37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/system-44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa.img.xz", + "hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", + "hash_raw": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", "size": 6291456000, - "sparse": true, + "sparse": false, "full_check": false, "has_ab": true, - "ondevice_hash": "f678dbc0ffb12e49e6b561ce58d84d3831dc7b7a595909f653b330ecec9c2c65", - "alt": { - "hash": "37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b.img", - "size": 6291456000 - } + "ondevice_hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", + "url_parts": 10 } ] diff --git a/iqpilot/system/hardware/tici/agnos.json.sig b/iqpilot/system/hardware/tici/agnos.json.sig index c93ccabbd..edfd31fba 100644 --- a/iqpilot/system/hardware/tici/agnos.json.sig +++ b/iqpilot/system/hardware/tici/agnos.json.sig @@ -1 +1 @@ -yxhDP2ieuolHlsCLh4gOQFHFGBIZ5TcewAlO8qo+F3L13hp4K0N5vniN5ZsC7K7Lg6z4TtehTycFb5o4IIIRBA== +IE+GKvyryxGDx98VImAM+UaqOPcs+mzWtlVs7r0h4Va2Tb9glgFBFlpaB3Btll+QI5zoo/tMlDrj463kaYQQBg== diff --git a/iqpilot/system/hardware/tici/agnos.py b/iqpilot/system/hardware/tici/agnos.py index a93b13aa4..435e11955 100755 --- a/iqpilot/system/hardware/tici/agnos.py +++ b/iqpilot/system/hardware/tici/agnos.py @@ -39,6 +39,81 @@ IQPILOT_MANIFEST_PUBLIC_KEY = bytes.fromhex("40ae3f81b77506ecc4982a1ca37ba1d6f87 AGNOS_MANIFEST_FILE = "system/hardware/tici/agnos.json" +LFS_POINTER_MAGIC = b"version https://git-lfs" + + +def _image_auth_module(): + """Return the git_remote auth module, or None. On IQ.OS this comes through the + verified loader; on stock AGNOS (an AGNOS->IQ.OS upgrade) that loader is not + present, but the compiled bundle IS in every checkout -- import it directly.""" + try: + from iqpilot.system.proprietary_runtime._verified_import import import_verified_module + return import_verified_module("iqpilot_updater_private", "iqpilot_private.updater.git_remote") + except Exception: + pass + try: + root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + bundle_python = os.path.join(root, "artifacts", "iqpilot_updater_private", "python") + if os.path.isdir(bundle_python): + if bundle_python not in sys.path: + sys.path.insert(0, bundle_python) + import importlib + return importlib.import_module("iqpilot_private.updater.git_remote") + except Exception: + pass + return None + + +def _download_headers(url: str) -> dict: + mod = _image_auth_module() + if mod is not None: + try: + headers = mod.os_image_headers(url) + if headers: + return headers + except Exception: + pass + try: + from iqpilot.common.git_creds import get_credentials + creds = get_credentials() + if creds and all(creds) and "/iq.lvbs/iqos" in url.lower(): + return {"Authorization": "Basic " + base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode()} + except Exception: + pass + return {} + + +def _open_image_response(url: str) -> requests.Response: + """GET an image URL; when the server answers with a Git-LFS pointer (the image + repo stores partitions as LFS objects and its raw endpoint does not resolve + them), follow it through the LFS batch API using the same credentials.""" + auth = _download_headers(url) + req = requests.get(url, stream=True, headers={'Accept-Encoding': None, **auth}, timeout=60) + req.raise_for_status() + if int(req.headers.get('content-length') or 0) >= 1024: + return req + + body = req.content + if not body.startswith(LFS_POINTER_MAGIC): + raise requests.exceptions.InvalidURL(f"unexpected tiny response ({len(body)} bytes) for {url}") + meta = dict(line.split(" ", 1) for line in body.decode().strip().splitlines() if " " in line) + oid = meta["oid"].split(":", 1)[1] + size = int(meta["size"]) + + batch_url = url.split("/raw/", 1)[0] + ".git/info/lfs/objects/batch" + batch = requests.post(batch_url, + data=json.dumps({"operation": "download", "transfers": ["basic"], + "objects": [{"oid": oid, "size": size}]}), + headers={"Content-Type": "application/vnd.git-lfs+json", + "Accept": "application/vnd.git-lfs+json", **auth}, + timeout=60) + batch.raise_for_status() + action = batch.json()["objects"][0]["actions"]["download"] + req = requests.get(action["href"], stream=True, + headers={'Accept-Encoding': None, **action.get("header", {})}, timeout=60) + req.raise_for_status() + return req + def verify_manifest_signature(manifest_path: str) -> None: sig_path = f"{manifest_path}.sig" @@ -54,11 +129,34 @@ def verify_manifest_signature(manifest_path: str) -> None: public_key.verify(signature, digest) +class _ChainedParts: + """Response-like wrapper streaming N sequential part files as one body. + + The image host caps single uploads well below the system image size, so big + images are stored as `.pNN` LFS objects; devices re-join them here.""" + + def __init__(self, urls: list[str]) -> None: + self.urls = urls + self.req: requests.Response | None = None + + def raise_for_status(self) -> None: + if self.req is not None: + self.req.raise_for_status() + + def iter_content(self, chunk_size: int) -> Generator[bytes, None, None]: + for u in self.urls: + self.req = _open_image_response(u) + yield from self.req.iter_content(chunk_size=chunk_size) + + class StreamingDecompressor: - def __init__(self, url: str) -> None: + def __init__(self, url: str, parts: int = 0) -> None: self.buf = b"" - self.req = requests.get(url, stream=True, headers={'Accept-Encoding': None}, timeout=60) + if parts > 1: + self.req = _ChainedParts([f"{url}.p{i:02d}" for i in range(parts)]) + else: + self.req = _open_image_response(url) self.it = self.req.iter_content(chunk_size=1024 * 1024) self.decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_AUTO) self.eof = False @@ -196,7 +294,7 @@ def clear_partition_hash(target_slot_number: int, partition: dict) -> None: def extract_compressed_image(target_slot_number: int, partition: dict, cloudlog): path = get_partition_path(target_slot_number, partition) - downloader = StreamingDecompressor(partition['url']) + downloader = StreamingDecompressor(partition['url'], parts=int(partition.get('url_parts', 0))) with open(path, 'wb+') as out: # Flash partition diff --git a/iqpilot/system/hardware/tici/agnos_tici_15_1.json b/iqpilot/system/hardware/tici/agnos_tici_15_1.json index aac270cc9..a8384399d 100644 --- a/iqpilot/system/hardware/tici/agnos_tici_15_1.json +++ b/iqpilot/system/hardware/tici/agnos_tici_15_1.json @@ -1,7 +1,7 @@ [ { "name": "xbl", - "url": "https://commadist.azureedge.net/agnosupdate/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/xbl-dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6.img.xz", "hash": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6", "hash_raw": "dd45c0febdf0e022dab82ed0219370a86e8e6c0dfabfe29f3dab7eb1174d6bc6", "size": 3282256, @@ -12,7 +12,7 @@ }, { "name": "xbl_config", - "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/xbl_config-1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9.img.xz", "hash": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9", "hash_raw": "1074ae051df159ba6dba988d8f6ba2cfc304ed1466cce0db531df6f7b1e44aa9", "size": 98124, @@ -23,7 +23,7 @@ }, { "name": "abl", - "url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", "hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", "hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", "size": 274432, @@ -34,7 +34,7 @@ }, { "name": "aop", - "url": "https://commadist.azureedge.net/agnosupdate/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/aop-4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788.img.xz", "hash": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788", "hash_raw": "4d925c9248672e4a69a236991983375008c44997a854ee7846d1b5fd7c787788", "size": 184364, @@ -45,7 +45,7 @@ }, { "name": "devcfg", - "url": "https://commadist.azureedge.net/agnosupdate/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/devcfg-2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585.img.xz", "hash": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585", "hash_raw": "2f374581243910db92f62bb13bd66ec8e3d56d434997ba007ded06d2d6cc8585", "size": 40336, @@ -56,7 +56,7 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb.img.xz", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/boot-aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb.img.xz", "hash": "aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb", "hash_raw": "aea4aecefd188d9c95726b699902378676c6266a7ae37008b5c2aa0e1e1190fb", "size": 18216960, @@ -67,18 +67,14 @@ }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b.img.xz", - "hash": "a7bec67c4fef85c66736e74ee8828cbef320f3729f72fee6bea4c97f23dc8b70", - "hash_raw": "37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b", + "url": "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqos/raw/branch/master/system-44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa.img.xz", + "hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", + "hash_raw": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", "size": 6291456000, - "sparse": true, + "sparse": false, "full_check": false, "has_ab": true, - "ondevice_hash": "f678dbc0ffb12e49e6b561ce58d84d3831dc7b7a595909f653b330ecec9c2c65", - "alt": { - "hash": "37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-37572981c7592d5dd20136e13bed9a9fae84ffc75b9b24da55704bbce7da239b.img", - "size": 6291456000 - } + "ondevice_hash": "44b251e1b3d8cd9243d5c79287d2a0e74b5063d3e21b24192b1293430a3471aa", + "url_parts": 10 } ] diff --git a/iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig b/iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig index a88a30dbc..a518d12bc 100644 --- a/iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig +++ b/iqpilot/system/hardware/tici/agnos_tici_15_1.json.sig @@ -1 +1 @@ -PvU/rNvS8JnrZabBbjgss1lyLIlUGXdEOsguFCMK22aY2p/MlzzzpI2+3Wz7tCNcPT+FEbLR1SrskEjTBvKsCQ== +9LDZugtT9q8jFab1Gs8qTmfJukKU8NzDAuT9VakInuOUHCCnN5SDFI/Ew/6C/+3SAlcROgZON/4J8FQyQGV+Dg== diff --git a/iqpilot/system/hardware/tici/tests/test_agnos_updater.py b/iqpilot/system/hardware/tici/tests/test_agnos_updater.py index 4f6512121..8f6cf9895 100644 --- a/iqpilot/system/hardware/tici/tests/test_agnos_updater.py +++ b/iqpilot/system/hardware/tici/tests/test_agnos_updater.py @@ -3,18 +3,42 @@ import os import requests TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) -MANIFEST = os.path.join(TEST_DIR, "../agnos.json") +MANIFESTS = [ + os.path.join(TEST_DIR, "../agnos.json"), + os.path.join(TEST_DIR, "../agnos_tici_15_1.json"), +] + +IMAGE_HOST = "gitlvb.teallvbs.xyz" + +# image payloads are xz streams; the repo raw endpoint would serve an LFS pointer +XZ_MAGIC = b"\xfd7zXZ\x00" +LFS_POINTER_MAGIC = b"version https://git-lfs" class TestAgnosUpdater: def test_manifest(self): - with open(MANIFEST) as f: - m = json.load(f) + for manifest in MANIFESTS: + with open(manifest) as f: + m = json.load(f) - for img in m: - r = requests.head(img['url'], timeout=10) - r.raise_for_status() - assert r.headers['Content-Type'].split(';', 1)[0] in {"application/x-xz", "application/octet-stream"} - if not img['sparse']: - assert img['hash'] == img['hash_raw'] + for img in m: + assert img['url'].split('/')[2] == IMAGE_HOST + if not img['sparse']: + assert img['hash'] == img['hash_raw'] + + # contract: images are distributed from a private repo, so an anonymous + # request must never receive image content. The denial status varies by + # route (404 via the CDN, catch-all HTML page when resolved directly to + # the origin), so assert on the payload, not the status code. trust_env + # off: requests otherwise picks up ~/.netrc (CI runners have gitlvb + # credentials), silently authenticating the "anonymous" probe. + s = requests.Session() + s.trust_env = False + r = s.get(img['url'], timeout=10, stream=True, + headers={"User-Agent": "IQOS-Updater"}) + if r.status_code in (401, 403, 404): + continue + head = next(r.iter_content(chunk_size=256), b"") or b"" + assert not head.startswith(XZ_MAGIC), f"{img['name']}: anonymous request served image content" + assert not head.startswith(LFS_POINTER_MAGIC), f"{img['name']}: anonymous request served the LFS pointer" diff --git a/iqpilot/system/manager/manager.py b/iqpilot/system/manager/manager.py index 166ac8d70..9a376619e 100755 --- a/iqpilot/system/manager/manager.py +++ b/iqpilot/system/manager/manager.py @@ -158,6 +158,10 @@ def manager_thread() -> None: started = sm['deviceState'].started if started and not started_prev: + try: + HARDWARE.set_power_save(False) + except Exception: + cloudlog.exception("failed to leave power save on onroad transition") params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION) elif not started and started_prev: params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION) diff --git a/iqpilot/system/manager/process_config.py b/iqpilot/system/manager/process_config.py index 0ac3555fe..7e1492154 100644 --- a/iqpilot/system/manager/process_config.py +++ b/iqpilot/system/manager/process_config.py @@ -195,7 +195,7 @@ procs = [ 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)), + NativeProcess("iqmodeld", "iqpilot/selfdrive/iqmodeld", ["./iqmodeld"], and_(only_onroad, is_tinygrad_model), 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)), diff --git a/iqpilot/system/ui/lib/os_update.py b/iqpilot/system/ui/lib/os_update.py index 738b9157f..bafec553e 100644 --- a/iqpilot/system/ui/lib/os_update.py +++ b/iqpilot/system/ui/lib/os_update.py @@ -42,10 +42,19 @@ def required_agnos_version(install_path: str) -> str: return "" +def _hardware_dir(install_path: str) -> str: + # the nested layout keeps the package at /iqpilot/; older checkouts had + # system/ at the top level. Accept either so callers can pass the install root. + 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(install_path, "system", "hardware", "tici", fname) + return os.path.join(_hardware_dir(install_path), fname) def os_update_needed(install_path: str) -> tuple[bool, str, str]: @@ -64,7 +73,7 @@ def run_agnos_update(install_path: str, device_type: str, progress_cb: ProgressC 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(install_path, "system", "hardware", "tici", "agnos.py") + 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