forked from IQ.Lvbs/IQ.Pilot
IQ.Pilot Release Commit @ d2ce8a8
This commit is contained in:
@@ -131,6 +131,7 @@ struct IQModelManager @0xe91d6987759290bb {
|
||||
policy @3;
|
||||
offPolicy @4;
|
||||
onPolicy @5;
|
||||
usbeMac @6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +441,9 @@ struct IQCarState @0xb1c39318bb6bc2b3 {
|
||||
# VW PQ stock ACC radar feedback for the IQ.Dynamics radar_manager (Blend feature)
|
||||
accRadarStaAdr @4 :UInt8; # ACC_System.ACS_Sta_ADR (0 not-active, 1 active, 2 passive, 3 irrev_Fehler)
|
||||
accRadarFehler @5 :Bool; # ACC_System.ACS_Fehler (stored fault -> radar dead for the drive)
|
||||
slcSetSpeedRequestId @6 :UInt32;
|
||||
slcSetSpeedGestureId @7 :UInt32;
|
||||
slcSetSpeedRequestKph @8 :Float32;
|
||||
}
|
||||
|
||||
struct IQLiveData @0xf2e2b608e51f4b0e {
|
||||
|
||||
@@ -135,6 +135,8 @@ struct OnroadEvent @0xc4fa6047f024e718 {
|
||||
userBookmark @95;
|
||||
excessiveActuation @96;
|
||||
audioFeedback @97;
|
||||
bigModelLoading @101;
|
||||
bigModelFailed @102;
|
||||
|
||||
soundsUnavailableDEPRECATED @47;
|
||||
}
|
||||
@@ -491,6 +493,13 @@ struct DeviceState @0xa4d8b5af2aa492eb {
|
||||
started @11 :Bool;
|
||||
startedMonoTime @13 :UInt64;
|
||||
|
||||
# ordinals track commaai/openpilot exactly (@50 bottomSocTempC, @51 dock
|
||||
# presence, @52 usbState) so upstream tooling stays able to read our logs;
|
||||
# only the field NAME differs, we do not use comma's internal dock codename.
|
||||
bottomSocTempC @50 :Float32;
|
||||
egpuDockPresent @51 :Bool;
|
||||
usbState @52 :UsbState;
|
||||
|
||||
# system utilization
|
||||
freeSpacePercent @7 :Float32;
|
||||
memoryUsagePercent @19 :Int8;
|
||||
@@ -586,6 +595,37 @@ struct DeviceState @0xa4d8b5af2aa492eb {
|
||||
nvmeTempCDEPRECATED @35 :List(Float32);
|
||||
}
|
||||
|
||||
struct UsbState {
|
||||
devices @0 :List(Device);
|
||||
# IQ extension (no upstream equivalent): controller-level link errors, needed
|
||||
# because portli is a CONTROLLER counter and in peripheral mode (eMac gadget
|
||||
# link) the peer never enumerates, so no Device row can carry it.
|
||||
linkErrorCount @1 :UInt32;
|
||||
# IQ extension, same reason: CC orientation describes the PORT, so it is
|
||||
# readable while the link is in peripheral mode. The per-device field below
|
||||
# matches upstream but only populates when something enumerates on that
|
||||
# controller (host mode, e.g. the eGPU dock) — never during an eMac session.
|
||||
usb3Lane @2 :Device.Usb3Lane;
|
||||
|
||||
struct Device {
|
||||
busnum @0 :UInt8;
|
||||
devnum @1 :UInt8;
|
||||
vendorId @2 :UInt16;
|
||||
productId @3 :UInt16;
|
||||
speedMbps @4 :UInt16;
|
||||
manufacturer @6 :Text;
|
||||
product @5 :Text;
|
||||
linkErrorCount @7 :UInt16;
|
||||
usb3Lane @8 :Usb3Lane;
|
||||
|
||||
enum Usb3Lane {
|
||||
unknown @0;
|
||||
a @1;
|
||||
b @2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PandaState @0xa7649e2575e4591e {
|
||||
ignitionLine @2 :Bool;
|
||||
rxBufferOverflow @7 :UInt32;
|
||||
@@ -745,6 +785,20 @@ struct PeripheralState {
|
||||
}
|
||||
}
|
||||
|
||||
struct EgpuDockState {
|
||||
tempC @0 :Float32;
|
||||
memoryTempC @1 :Float32;
|
||||
powerDrawW @2 :Float32;
|
||||
powerLimitW @3 :Float32;
|
||||
gpuUsagePercent @4 :UInt8;
|
||||
gpuClockMhz @5 :UInt16;
|
||||
fanSpeedRpm @6 :UInt16;
|
||||
pcieLtssm @7 :UInt8;
|
||||
supplyVoltage @8 :UInt16; # mV
|
||||
supplyCurrent @9 :Int16; # mA
|
||||
supplyFault @10 :Bool;
|
||||
}
|
||||
|
||||
struct RadarState @0x9a185389d6fdd05f {
|
||||
mdMonoTime @6 :UInt64;
|
||||
carStateMonoTime @11 :UInt64;
|
||||
@@ -1078,6 +1132,7 @@ struct ModelDataV2 {
|
||||
timestampEof @3 :UInt64;
|
||||
modelExecutionTime @15 :Float32;
|
||||
rawPredictions @16 :Data;
|
||||
big @27 :Bool;
|
||||
|
||||
# predicted future position, orientation, etc..
|
||||
position @4 :XYZTData;
|
||||
@@ -2668,6 +2723,7 @@ struct Event {
|
||||
iqState @153 :Custom.IQState;
|
||||
iqModelManager @154 :Custom.IQModelManager;
|
||||
iqPlan @155 :Custom.IQPlan;
|
||||
egpuDockState @166 :EgpuDockState;
|
||||
iqOnroadEvents @156 :Custom.IQOnroadEvent;
|
||||
iqCarParams @157 :Custom.IQCarParams;
|
||||
iqCarControl @158 :Custom.IQCarControl;
|
||||
|
||||
@@ -108,6 +108,7 @@ _services: dict[str, tuple] = {
|
||||
"iqRoadIncidentFeed": (True, 0.2, 1),
|
||||
"iqNavRenderState": (True, 5., 10, QueueSize.MEDIUM),
|
||||
"iqState": (True, 100., 10),
|
||||
"egpuDockState": (True, 10., 10),
|
||||
"iqPlan": (True, 20., 10),
|
||||
"iqOnroadEvents": (True, 1., 1),
|
||||
"iqDriveModelData": (True, 20., None, QueueSize.MEDIUM),
|
||||
|
||||
@@ -105,6 +105,13 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
|
||||
{"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
|
||||
{"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
|
||||
{"Offroad_EgpuNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
|
||||
{"Offroad_EgpuFansObstructed", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_EgpuOverheated", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_EgpuPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_EgpuUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
|
||||
{"Offroad_EgpuUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
|
||||
{"Offroad_EgpuUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
|
||||
{"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_ExcessiveActuation", {PERSISTENT, JSON}},
|
||||
@@ -224,6 +231,34 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
|
||||
{"ModelManager_ModelsCache", {PERSISTENT, JSON}},
|
||||
|
||||
{"IQEmacEnabled", {PERSISTENT, BOOL, "0"}},
|
||||
{"IQEmacHost", {PERSISTENT, STRING}},
|
||||
{"IQEmacModel", {PERSISTENT, STRING}},
|
||||
{"IQEmacCatalogCache", {PERSISTENT, STRING}},
|
||||
{"MacModelDownloadProgress", {CLEAR_ON_MANAGER_START, STRING, "1.0"}},
|
||||
{"MacModelStatus", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"MacModelPresent", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"MacModelReachable", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"MacModelReady", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"MacModelActive", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"MacModelFailed", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"MacModelLastError", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"MacModelLatencyMs", {CLEAR_ON_MANAGER_START, FLOAT, "0.0"}},
|
||||
|
||||
// comma USB eGPU big-model backend. Mutually exclusive with eMac at
|
||||
// runtime (eMac wins). UsbGpu* naming/flags mirror comma's handover branch
|
||||
{"IQEgpuEnabled", {PERSISTENT, BOOL, "0"}},
|
||||
{"IQEgpuDisabled", {PERSISTENT, BOOL, "0"}},
|
||||
{"UsbGpuPresent", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
|
||||
{"UsbGpuCompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
|
||||
{"UsbGpuLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"UsbGpuFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"UsbGpuLastError", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"UsbGpuLatencyMs", {CLEAR_ON_MANAGER_START, FLOAT, "0.0"}},
|
||||
{"UsbGpuStatus", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"UsbGpuSetupProgress", {CLEAR_ON_MANAGER_START, STRING, "1.0"}},
|
||||
|
||||
// Neural Network Feed Forward
|
||||
{"NeuralNetworkFeedForward", {PERSISTENT, BOOL, "0"}},
|
||||
|
||||
|
||||
@@ -253,6 +253,9 @@ class Car:
|
||||
# TODO: mirror the carState.cruiseState struct?
|
||||
CS.vCruise = float(self.v_cruise_helper.v_cruise_kph)
|
||||
CS.vCruiseCluster = float(self.v_cruise_helper.v_cruise_cluster_kph)
|
||||
CS_IQ.slcSetSpeedRequestId = self.v_cruise_helper.slc_set_speed_request_id
|
||||
CS_IQ.slcSetSpeedGestureId = self.v_cruise_helper.slc_set_speed_gesture_id
|
||||
CS_IQ.slcSetSpeedRequestKph = self.v_cruise_helper.slc_set_speed_request_kph
|
||||
|
||||
return CS, CS_IQ, RD
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from iqpilot.common.constants import CV
|
||||
from iqpilot.cereal import car, custom
|
||||
from iqdbc.car import structs
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.realtime import DT_CTRL
|
||||
from iqpilot.selfdrive.car.long_increments import LongIncrementConfig, read_long_increment_config, resolve_button_step
|
||||
|
||||
|
||||
@@ -237,6 +238,35 @@ class VCruiseHelper(VCruiseHelperIQ):
|
||||
self.v_cruise_kph_last = 0
|
||||
self.button_timers = {ButtonType.decelCruise: 0, ButtonType.accelCruise: 0}
|
||||
self.button_change_states = {btn: {"standstill": False, "enabled": False} for btn in self.button_timers}
|
||||
self.slc_set_speed_request_id = 0
|
||||
self.slc_set_speed_gesture_id = 0
|
||||
self.slc_set_speed_request_kph = 0.0
|
||||
self._slc_accel_held = False
|
||||
self._slc_accel_release_frames = 0
|
||||
self._slc_pcm_speed_last = None
|
||||
|
||||
def _update_slc_accel_gesture(self, CS):
|
||||
self._slc_accel_release_frames = max(0, self._slc_accel_release_frames - 1)
|
||||
for button in CS.buttonEvents:
|
||||
if button.type in (ButtonType.accelCruise, ButtonType.resumeCruise):
|
||||
if button.pressed:
|
||||
self.slc_set_speed_gesture_id = (self.slc_set_speed_gesture_id + 1) % (1 << 32)
|
||||
self._slc_accel_release_frames = 0
|
||||
else:
|
||||
self._slc_accel_release_frames = int(0.5 / DT_CTRL)
|
||||
self._slc_accel_held = button.pressed
|
||||
elif button.pressed:
|
||||
self._slc_accel_held = False
|
||||
self._slc_accel_release_frames = 0
|
||||
if not CS.cruiseState.available:
|
||||
self._slc_accel_held = False
|
||||
self._slc_accel_release_frames = 0
|
||||
|
||||
def _record_slc_set_speed_increase(self, previous_kph, enabled):
|
||||
if enabled and (self._slc_accel_held or self._slc_accel_release_frames > 0) and \
|
||||
previous_kph is not None and 0 < previous_kph < self.v_cruise_kph <= V_CRUISE_MAX:
|
||||
self.slc_set_speed_request_id = (self.slc_set_speed_request_id + 1) % (1 << 32)
|
||||
self.slc_set_speed_request_kph = float(self.v_cruise_kph)
|
||||
|
||||
@property
|
||||
def v_cruise_initialized(self):
|
||||
@@ -248,6 +278,7 @@ class VCruiseHelper(VCruiseHelperIQ):
|
||||
|
||||
def update_v_cruise(self, CS, enabled, is_metric):
|
||||
self.v_cruise_kph_last = self.v_cruise_kph
|
||||
self._update_slc_accel_gesture(CS)
|
||||
|
||||
self.get_minimum_set_speed(is_metric)
|
||||
|
||||
@@ -256,12 +287,15 @@ class VCruiseHelper(VCruiseHelperIQ):
|
||||
if not self.CP.pcmCruise or (not self.CP_IQ.pcmCruiseSpeed and _enabled):
|
||||
# if stock cruise is completely disabled, then we can use our own set speed logic
|
||||
self._update_v_cruise_non_pcm(CS, _enabled, is_metric, self.volkswagen_standby_set_speed)
|
||||
self._record_slc_set_speed_increase(self.v_cruise_kph_last, _enabled)
|
||||
self.update_speed_limit_assist_v_cruise_non_pcm()
|
||||
self.v_cruise_cluster_kph = self.v_cruise_kph
|
||||
self.update_button_timers(CS, enabled)
|
||||
else:
|
||||
self.v_cruise_kph = CS.cruiseState.speed * CV.MS_TO_KPH
|
||||
self.v_cruise_cluster_kph = CS.cruiseState.speedCluster * CV.MS_TO_KPH
|
||||
self._record_slc_set_speed_increase(self._slc_pcm_speed_last, _enabled)
|
||||
self._slc_pcm_speed_last = self.v_cruise_kph
|
||||
if CS.cruiseState.speed == 0:
|
||||
self.v_cruise_kph = V_CRUISE_UNSET
|
||||
self.v_cruise_cluster_kph = V_CRUISE_UNSET
|
||||
@@ -273,6 +307,7 @@ class VCruiseHelper(VCruiseHelperIQ):
|
||||
else:
|
||||
self.v_cruise_kph = V_CRUISE_UNSET
|
||||
self.v_cruise_cluster_kph = V_CRUISE_UNSET
|
||||
self._slc_pcm_speed_last = None
|
||||
|
||||
def _update_v_cruise_non_pcm(self, CS, enabled, is_metric, allow_standby_adjustment=False):
|
||||
# handle button presses. TODO: this should be in state_control, but a decelCruise press
|
||||
|
||||
@@ -73,6 +73,65 @@ class TestSpeedLimitSetSpeedMirror:
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == pytest.approx(13.41 * CV.MS_TO_KPH, abs=0.1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pcm_cruise", [False, True])
|
||||
@pytest.mark.parametrize("is_metric", [False, True])
|
||||
def test_driver_increase_marker_survives_button_release(pcm_cruise, is_metric):
|
||||
helper = VCruiseHelper(car.CarParams(pcmCruise=pcm_cruise), custom.IQCarParams(pcmCruiseSpeed=True))
|
||||
helper.set_speed_to_limit = False
|
||||
helper.v_cruise_kph = helper.v_cruise_cluster_kph = 80.0
|
||||
state = car.CarState(cruiseState={"available": True, "speed": 80 * CV.KPH_TO_MS, "speedCluster": 80 * CV.KPH_TO_MS})
|
||||
helper.update_v_cruise(state, True, is_metric)
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type="accelCruise", pressed=True)]
|
||||
helper.update_v_cruise(state, True, is_metric)
|
||||
assert helper.slc_set_speed_request_id == 0
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type="accelCruise", pressed=False)]
|
||||
if pcm_cruise:
|
||||
state.cruiseState.speed = state.cruiseState.speedCluster = 81 * CV.KPH_TO_MS
|
||||
helper.update_v_cruise(state, True, is_metric)
|
||||
request = custom.IQCarState.new_message(
|
||||
slcSetSpeedRequestId=helper.slc_set_speed_request_id,
|
||||
slcSetSpeedGestureId=helper.slc_set_speed_gesture_id,
|
||||
slcSetSpeedRequestKph=helper.slc_set_speed_request_kph,
|
||||
)
|
||||
assert request.slcSetSpeedRequestId == 1
|
||||
assert request.slcSetSpeedRequestKph == pytest.approx(helper.v_cruise_kph)
|
||||
state.buttonEvents = []
|
||||
for _ in range(100):
|
||||
helper.update_v_cruise(state, True, is_metric)
|
||||
assert helper.slc_set_speed_request_id == request.slcSetSpeedRequestId
|
||||
assert helper.slc_set_speed_gesture_id == request.slcSetSpeedGestureId
|
||||
|
||||
|
||||
def test_stock_cruise_sync_is_not_a_driver_increase():
|
||||
helper = VCruiseHelper(car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True),
|
||||
custom.IQCarParams(pcmCruiseSpeed=True))
|
||||
helper.set_speed_to_limit = True
|
||||
helper.update_speed_limit_assist(True, TestSpeedLimitSetSpeedMirror._iq_plan(
|
||||
50 * CV.KPH_TO_MS, custom.IQPlan.SpeedLimit.AssistState.active))
|
||||
state = car.CarState(cruiseState={"available": True, "speed": 80 * CV.KPH_TO_MS, "speedCluster": 80 * CV.KPH_TO_MS})
|
||||
for _ in range(10):
|
||||
helper.update_v_cruise(state, True, True)
|
||||
assert helper.slc_set_speed_request_id == 0
|
||||
state.cruiseState.speed = state.cruiseState.speedCluster = 90 * CV.KPH_TO_MS
|
||||
helper.update_v_cruise(state, True, True)
|
||||
assert helper.slc_set_speed_request_id == 0
|
||||
|
||||
|
||||
def test_held_increase_uses_one_gesture_and_multiple_requests():
|
||||
helper = VCruiseHelper(car.CarParams(pcmCruise=False), custom.IQCarParams(pcmCruiseSpeed=True))
|
||||
helper.set_speed_to_limit = False
|
||||
helper.v_cruise_kph = 80.0
|
||||
state = car.CarState(cruiseState={"available": True})
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type="accelCruise", pressed=True)]
|
||||
helper.update_v_cruise(state, True, True)
|
||||
state.buttonEvents = []
|
||||
for _ in range(110):
|
||||
helper.update_v_cruise(state, True, True)
|
||||
assert helper.slc_set_speed_gesture_id == 1
|
||||
assert helper.slc_set_speed_request_id >= 2
|
||||
assert helper.slc_set_speed_request_kph == pytest.approx(helper.v_cruise_kph)
|
||||
|
||||
|
||||
def test_set_speed_does_not_follow_limit_when_feature_off():
|
||||
# Default off: set speed must stay the driver's value (limiter-only via planner min-blend).
|
||||
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True)
|
||||
|
||||
@@ -24,6 +24,7 @@ from iqpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
|
||||
from iqpilot.selfdrive.controls.lib.latcontrol_torque_pq import LatControlTorquePQ
|
||||
from iqpilot.selfdrive.controls.lib.latcontrol_torque_v0 import LatControlTorqueV0, is_vw_mqb_torque
|
||||
from iqpilot.selfdrive.controls.lib.longcontrol import LongControl
|
||||
from iqpilot.selfdrive.controls.steering_fault_recovery import SteeringFaultRecovery
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
from iqpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose
|
||||
|
||||
@@ -73,6 +74,7 @@ class Controls(IQControlsLayer):
|
||||
self.pm = messaging.PubMaster(['carControl', 'controlsState', 'iqPerfTrace'] + self.iq_pub_services)
|
||||
|
||||
self.steer_limited_by_safety = False
|
||||
self.steering_fault_recovery = SteeringFaultRecovery()
|
||||
self.curvature = 0.0
|
||||
self.desired_curvature = 0.0
|
||||
self.roll_compensation = 0.0
|
||||
@@ -208,7 +210,8 @@ class Controls(IQControlsLayer):
|
||||
# Get which state to use for active lateral control
|
||||
_lat_active = self.iq_lateral_allowed(self.sm)
|
||||
|
||||
CC.latActive = _lat_active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and \
|
||||
steering_fault_recovered = self.steering_fault_recovery.update(CS.steerFaultTemporary, CS.steerFaultPermanent)
|
||||
CC.latActive = _lat_active and steering_fault_recovered and \
|
||||
(not standstill or self.CP.steerAtStandstill)
|
||||
# long control may stay active through a gas override on platforms that opt in
|
||||
override_longitudinal = any(e.overrideLongitudinal for e in self.sm['onroadEvents'])
|
||||
|
||||
@@ -189,6 +189,8 @@ class SLCVCruise:
|
||||
self._user_max_speed = v_cruise_cluster
|
||||
else:
|
||||
self._user_max_speed = 0.0
|
||||
if not slc_params["speed_limit_controller"]:
|
||||
self.slc.reset_override(sm)
|
||||
if slc_params["speed_limit_controller"]:
|
||||
self.slc.update_limits(dashboard_speed_limit, now, time_validated, v_cruise, v_ego, sm, slc_params)
|
||||
self.pending_events = list(getattr(self.slc, 'pending_events', []))
|
||||
|
||||
@@ -261,10 +261,11 @@ class IQSpeedLimitAssist:
|
||||
for btn in sm["carState"].buttonEvents:
|
||||
if btn.pressed:
|
||||
continue
|
||||
if is_lower and btn.type in CONFIRM_LOWER_BUTTONS:
|
||||
button_type = getattr(btn.type, "raw", btn.type)
|
||||
if is_lower and button_type in CONFIRM_LOWER_BUTTONS:
|
||||
confirmed = True
|
||||
break
|
||||
elif not is_lower and btn.type in CONFIRM_HIGHER_BUTTONS:
|
||||
elif not is_lower and button_type in CONFIRM_HIGHER_BUTTONS:
|
||||
confirmed = True
|
||||
break
|
||||
except (AttributeError, TypeError):
|
||||
@@ -314,6 +315,10 @@ class SpeedLimitController:
|
||||
|
||||
self.override_slc = False
|
||||
self.overridden_speed = 0.0
|
||||
self._last_override_request_id = 0
|
||||
self._blocked_override_gesture = 0
|
||||
self._override_limit = None
|
||||
self._override_set_speed = False
|
||||
|
||||
self._resolved_limit = 0.0
|
||||
self._resolved_source = "None"
|
||||
@@ -807,9 +812,44 @@ class SpeedLimitController:
|
||||
self.pending_events.append(EventNameIQ.constructionZoneDetected)
|
||||
self._czone_was_limiting = czone_limiting
|
||||
|
||||
def reset_override(self, sm):
|
||||
self.override_slc = False
|
||||
self.overridden_speed = 0.0
|
||||
self._last_override_request_id = int(getattr(sm["iqCarState"], "slcSetSpeedRequestId", 0))
|
||||
self._blocked_override_gesture = int(getattr(sm["iqCarState"], "slcSetSpeedGestureId", 0))
|
||||
self._override_limit = None
|
||||
|
||||
def update_override(self, v_cruise, v_cruise_diff, v_ego, v_ego_diff, sm, slc_params, is_metric):
|
||||
offset = self.get_offset(is_metric)
|
||||
target = self._assist.target
|
||||
set_speed_override = slc_params.get("speed_limit_controller_override_set_speed", False)
|
||||
mode_changed = set_speed_override != self._override_set_speed
|
||||
self._override_set_speed = set_speed_override
|
||||
|
||||
if set_speed_override:
|
||||
request_id = int(getattr(sm["iqCarState"], "slcSetSpeedRequestId", 0))
|
||||
gesture_id = int(getattr(sm["iqCarState"], "slcSetSpeedGestureId", 0))
|
||||
request_speed = float(getattr(sm["iqCarState"], "slcSetSpeedRequestKph", 0.0)) * CV.KPH_TO_MS
|
||||
new_request = request_id != self._last_override_request_id
|
||||
limit = (target, self._assist.source)
|
||||
reset = (mode_changed or limit != self._override_limit or self._assist.just_confirmed or
|
||||
self._assist.state == SpeedLimitAssistState.preActive or
|
||||
not bool(getattr(sm["selfdriveState"], "enabled", False)) or target <= 0 or self._resolved_source == "Construction")
|
||||
cruise_speed = v_cruise + v_cruise_diff
|
||||
above_limit = cruise_speed > target + offset + 1e-3
|
||||
if reset or (self.override_slc and not above_limit):
|
||||
self.reset_override(sm)
|
||||
elif above_limit:
|
||||
driver_increase = new_request and gesture_id != self._blocked_override_gesture and request_speed > target + offset + 1e-3
|
||||
gas_override = sm["carState"].gasPressed and v_ego > target + offset
|
||||
self.override_slc = self.override_slc or driver_increase or gas_override
|
||||
self.overridden_speed = cruise_speed if self.override_slc else 0.0
|
||||
self._last_override_request_id = request_id
|
||||
self._override_limit = limit
|
||||
return
|
||||
|
||||
if mode_changed:
|
||||
self.reset_override(sm)
|
||||
|
||||
self.override_slc = self.overridden_speed > target + offset > 0
|
||||
self.override_slc |= sm["carState"].gasPressed and v_ego > target + offset > 0
|
||||
@@ -820,7 +860,5 @@ class SpeedLimitController:
|
||||
if sm["carState"].gasPressed:
|
||||
self.overridden_speed = max(v_ego + v_ego_diff, self.overridden_speed)
|
||||
self.overridden_speed = float(np.clip(self.overridden_speed, target + offset, v_cruise + v_cruise_diff))
|
||||
elif slc_params.get("speed_limit_controller_override_set_speed", False):
|
||||
self.overridden_speed = v_cruise + v_cruise_diff
|
||||
else:
|
||||
self.overridden_speed = 0.0
|
||||
|
||||
@@ -5,8 +5,13 @@ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.cereal import car, custom
|
||||
from iqpilot.common.constants import CV
|
||||
from iqpilot.common.slc_variables import OFFSET_MAP_IMPERIAL
|
||||
from iqpilot.selfdrive.car.cruise import VCruiseHelper
|
||||
from iqpilot.selfdrive.controls.lib.iq_longitudinal_planner import LongitudinalPlannerIQ
|
||||
from iqpilot.selfdrive.controls.lib.slc_vcruise import SLCVCruise, CRUISING_SPEED
|
||||
from iqpilot.selfdrive.controls.lib.speed_limit_controller import SpeedLimitController, POLICY_MAP_DATA_PRIORITY, POLICY_COMBINED
|
||||
|
||||
@@ -61,6 +66,9 @@ class _FakeSLC:
|
||||
def update_override(self, *_args, **_kwargs):
|
||||
self.update_override_calls += 1
|
||||
|
||||
def reset_override(self, _sm):
|
||||
self.overridden_speed = 0.0
|
||||
|
||||
def get_offset(self, _is_metric):
|
||||
return self._offset
|
||||
|
||||
@@ -261,6 +269,200 @@ def test_slc_vcruise_does_not_auto_raise_when_higher_confirmation_enabled():
|
||||
assert out == v_cruise
|
||||
|
||||
|
||||
@pytest.fixture(params=[True, False], ids=["metric", "imperial"])
|
||||
def set_speed_slc(request, monkeypatch):
|
||||
monkeypatch.setattr("iqpilot.selfdrive.controls.lib.slc_vcruise.Params", FakeParams)
|
||||
slc = SLCVCruise()
|
||||
slc._maybe_log_debug = lambda *_args: None
|
||||
slc.slc.update_gps = lambda _sm: None
|
||||
slc.slc._resolver.update_map_data = lambda *_args: None
|
||||
params = _base_slc_params_controller() | {
|
||||
"speed_limit_controller": True,
|
||||
"speed_limit_mode": 3,
|
||||
"show_speed_limits": False,
|
||||
"is_metric": request.param,
|
||||
"slc_online_filler": False,
|
||||
"slc_fallback_experimental_mode": False,
|
||||
"speed_limit_controller_override_manual": False,
|
||||
"speed_limit_controller_override_set_speed": True,
|
||||
}
|
||||
slc._get_slc_params = lambda: params
|
||||
unit = CV.KPH_TO_MS if request.param else CV.MPH_TO_MS
|
||||
slc.slc._resolver.map_speed_limit = 50 * unit
|
||||
sm = _FakeSM(_build_sm(v_ego_cluster=50 * unit))
|
||||
sm["iqCarState"].slcSetSpeedRequestId = 0
|
||||
sm["iqCarState"].slcSetSpeedGestureId = 0
|
||||
sm["iqCarState"].slcSetSpeedRequestKph = 0.0
|
||||
|
||||
def step(speed, increase=False, new_gesture=False):
|
||||
sm["carState"].vCruiseCluster = speed * unit * CV.MS_TO_KPH
|
||||
if new_gesture:
|
||||
sm["iqCarState"].slcSetSpeedGestureId += 1
|
||||
if increase:
|
||||
sm["iqCarState"].slcSetSpeedRequestId += 1
|
||||
sm["iqCarState"].slcSetSpeedRequestKph = sm["carState"].vCruiseCluster
|
||||
target = slc.update(sm["selfdriveState"].enabled, None, True, speed * unit, 50 * unit, sm)
|
||||
return min(speed * unit, target) / unit
|
||||
|
||||
step(50)
|
||||
return SimpleNamespace(slc=slc, params=params, sm=sm, unit=unit, step=step)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("confirm_higher", [False, True])
|
||||
def test_set_speed_override_tracks_driver_adjustments(set_speed_slc, confirm_higher):
|
||||
system = set_speed_slc
|
||||
system.params["speed_limit_confirmation_higher"] = confirm_higher
|
||||
assert system.step(50, new_gesture=True) == pytest.approx(50)
|
||||
assert system.step(55, increase=True) == pytest.approx(55)
|
||||
assert system.step(60, increase=True) == pytest.approx(60)
|
||||
assert system.step(60) == pytest.approx(60)
|
||||
assert system.step(55) == pytest.approx(55)
|
||||
assert system.step(50) == pytest.approx(50)
|
||||
assert not system.slc.slc.override_slc
|
||||
assert system.step(45) == pytest.approx(45)
|
||||
assert system.step(60) == pytest.approx(50)
|
||||
assert system.step(61, increase=True, new_gesture=True) == pytest.approx(61)
|
||||
|
||||
|
||||
def test_set_speed_override_ignores_automatic_speed_changes(set_speed_slc):
|
||||
system = set_speed_slc
|
||||
assert system.step(80) == pytest.approx(50)
|
||||
system.slc.slc._resolver.map_speed_limit = 60 * system.unit
|
||||
assert system.step(60) == pytest.approx(60)
|
||||
assert system.step(80) == pytest.approx(60)
|
||||
assert system.slc.slc.overridden_speed == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [40, 55])
|
||||
def test_set_speed_override_resets_on_accepted_limit(set_speed_slc, limit):
|
||||
system = set_speed_slc
|
||||
assert system.step(60, increase=True, new_gesture=True) == pytest.approx(60)
|
||||
system.slc.slc._resolver.map_speed_limit = limit * system.unit
|
||||
assert system.step(65, increase=True) == pytest.approx(limit)
|
||||
assert system.step(70, increase=True) == pytest.approx(limit)
|
||||
assert system.step(71, increase=True, new_gesture=True) == pytest.approx(71)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit,button", [(40, "decelCruise"), (55, "accelCruise")])
|
||||
def test_set_speed_override_does_not_reuse_confirmation_gesture(set_speed_slc, limit, button):
|
||||
system = set_speed_slc
|
||||
system.params["speed_limit_confirmation_higher"] = True
|
||||
system.params["speed_limit_confirmation_lower"] = True
|
||||
system.slc.slc._resolver.map_speed_limit = limit * system.unit
|
||||
system.step(50, new_gesture=True)
|
||||
assert system.slc.assist_state == custom.IQPlan.SpeedLimit.AssistState.preActive
|
||||
assert system.step(60, increase=True) == pytest.approx(50)
|
||||
system.sm["carState"].buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
assert system.step(61, increase=True) == pytest.approx(limit)
|
||||
system.sm["carState"].buttonEvents = []
|
||||
assert system.step(65, increase=True) == pytest.approx(limit)
|
||||
assert system.step(66, increase=True, new_gesture=True) == pytest.approx(66)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reset", ["disengage", "information", "off", "missing_limit"])
|
||||
def test_set_speed_override_cannot_survive_reset(set_speed_slc, reset):
|
||||
system = set_speed_slc
|
||||
assert system.step(60, increase=True, new_gesture=True) == pytest.approx(60)
|
||||
if reset == "disengage":
|
||||
system.sm["selfdriveState"].enabled = False
|
||||
elif reset in ("information", "off"):
|
||||
system.params["speed_limit_controller"] = False
|
||||
system.params["show_speed_limits"] = reset == "information"
|
||||
else:
|
||||
system.slc.slc._resolver.map_speed_limit = 0
|
||||
system.step(60)
|
||||
assert system.slc.slc.overridden_speed == 0
|
||||
system.sm["selfdriveState"].enabled = True
|
||||
system.params["speed_limit_controller"] = True
|
||||
system.slc.slc._resolver.map_speed_limit = 50 * system.unit
|
||||
assert system.step(60) == pytest.approx(50)
|
||||
assert system.step(65, increase=True) == pytest.approx(50)
|
||||
assert system.step(66, increase=True, new_gesture=True) == pytest.approx(66)
|
||||
|
||||
|
||||
def test_set_speed_override_respects_offset(set_speed_slc):
|
||||
system = set_speed_slc
|
||||
system.slc.slc.params.put("speed_limit_offset1", 10)
|
||||
system.slc.slc.params.put("speed_limit_offset2", 10)
|
||||
system.slc.slc.params.put("speed_limit_offset3", 10)
|
||||
system.slc.slc._offset_cache.clear()
|
||||
system.step(50, new_gesture=True)
|
||||
assert system.step(52, increase=True) == pytest.approx(52)
|
||||
assert not system.slc.slc.override_slc
|
||||
assert system.step(56, increase=True) == pytest.approx(56)
|
||||
assert system.step(55) == pytest.approx(55)
|
||||
assert not system.slc.slc.override_slc
|
||||
|
||||
|
||||
def test_manual_override_still_requires_accelerator(set_speed_slc):
|
||||
system = set_speed_slc
|
||||
system.params["speed_limit_controller_override_set_speed"] = False
|
||||
system.params["speed_limit_controller_override_manual"] = True
|
||||
assert system.step(60, increase=True, new_gesture=True) == pytest.approx(50)
|
||||
system.sm["carState"].gasPressed = True
|
||||
system.sm["carState"].vEgoCluster = 55 * system.unit
|
||||
system.slc.slc.update_override(60 * system.unit, 0, 55 * system.unit, 0, system.sm, system.params, system.params["is_metric"])
|
||||
assert system.slc.slc.overridden_speed == pytest.approx(55 * system.unit)
|
||||
system.sm["carState"].gasPressed = False
|
||||
system.slc.slc.update_override(60 * system.unit, 0, 55 * system.unit, 0, system.sm, system.params, system.params["is_metric"])
|
||||
assert system.slc.slc.overridden_speed == pytest.approx(55 * system.unit)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pcm_cruise", [False, True])
|
||||
def test_driver_increase_reaches_slc_without_transient_button_events(set_speed_slc, pcm_cruise):
|
||||
system = set_speed_slc
|
||||
helper = VCruiseHelper(car.CarParams(pcmCruise=pcm_cruise), custom.IQCarParams(pcmCruiseSpeed=True))
|
||||
helper.set_speed_to_limit = False
|
||||
helper.v_cruise_kph = helper.v_cruise_cluster_kph = 50 * system.unit * CV.MS_TO_KPH
|
||||
state = car.CarState(cruiseState={"available": True, "speed": 50 * system.unit, "speedCluster": 50 * system.unit})
|
||||
helper.update_v_cruise(state, True, system.params["is_metric"])
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type="accelCruise", pressed=True)]
|
||||
helper.update_v_cruise(state, True, system.params["is_metric"])
|
||||
state.buttonEvents = [car.CarState.ButtonEvent(type="accelCruise", pressed=False)]
|
||||
if pcm_cruise:
|
||||
state.cruiseState.speed = state.cruiseState.speedCluster = 51 * system.unit
|
||||
helper.update_v_cruise(state, True, system.params["is_metric"])
|
||||
state.buttonEvents = []
|
||||
for _ in range(5):
|
||||
helper.update_v_cruise(state, True, system.params["is_metric"])
|
||||
system.sm["iqCarState"] = custom.IQCarState.new_message(
|
||||
slcSetSpeedRequestId=helper.slc_set_speed_request_id,
|
||||
slcSetSpeedGestureId=helper.slc_set_speed_gesture_id,
|
||||
slcSetSpeedRequestKph=helper.slc_set_speed_request_kph,
|
||||
)
|
||||
requested = helper.v_cruise_kph * CV.KPH_TO_MS / system.unit
|
||||
assert requested > 50
|
||||
assert system.step(requested) == pytest.approx(requested)
|
||||
|
||||
|
||||
def test_set_speed_override_keeps_navigation_constraint(set_speed_slc):
|
||||
system = set_speed_slc
|
||||
assert system.step(60, increase=True, new_gesture=True) == pytest.approx(60)
|
||||
planner = LongitudinalPlannerIQ.__new__(LongitudinalPlannerIQ)
|
||||
planner.slimit = system.slc
|
||||
planner.iq_dynamic = SimpleNamespace(
|
||||
set_slc_experimental_mode=lambda _mode: None, update=lambda _sm: None, force_stop_requested=lambda: False)
|
||||
planner.force_stop_timer = 0.0
|
||||
planner.override_force_stop_timer = 0.0
|
||||
planner.override_force_stop = False
|
||||
system.sm["iqNavState"] = SimpleNamespace(longitudinalEngaged=False, valid=False)
|
||||
assert planner.update_targets(system.sm, 50 * system.unit, 60 * system.unit) == pytest.approx(60 * system.unit)
|
||||
system.sm["iqNavState"] = SimpleNamespace(longitudinalEngaged=True, valid=True, speedTarget=40 * system.unit)
|
||||
assert planner.update_targets(system.sm, 50 * system.unit, 60 * system.unit) == pytest.approx(40 * system.unit)
|
||||
|
||||
|
||||
def test_set_speed_override_cannot_bypass_construction_zone(set_speed_slc):
|
||||
system = set_speed_slc
|
||||
assert system.step(60, increase=True, new_gesture=True) == pytest.approx(60)
|
||||
system.params["construction_zone_assist"] = True
|
||||
system.params["construction_zone_speed"] = 40
|
||||
system.sm["iqConstructionZone"] = SimpleNamespace(active=True)
|
||||
system.sm.alive["iqConstructionZone"] = True
|
||||
assert system.step(60) == pytest.approx(40)
|
||||
assert system.step(65, increase=True, new_gesture=True) == pytest.approx(40)
|
||||
assert not system.slc.slc.override_slc
|
||||
|
||||
|
||||
class _FakeSM(dict):
|
||||
def __init__(self, services, alive=None):
|
||||
super().__init__(services)
|
||||
|
||||
18
iqpilot/selfdrive/controls/steering_fault_recovery.py
Normal file
18
iqpilot/selfdrive/controls/steering_fault_recovery.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from iqpilot.common.realtime import DT_CTRL
|
||||
|
||||
STEER_FAULT_RECOVERY_FRAMES = int(1.0 / DT_CTRL)
|
||||
|
||||
|
||||
class SteeringFaultRecovery:
|
||||
def __init__(self) -> None:
|
||||
self.clear_frames = STEER_FAULT_RECOVERY_FRAMES
|
||||
|
||||
def update(self, temporary: bool, permanent: bool) -> bool:
|
||||
if temporary or permanent:
|
||||
self.clear_frames = 0
|
||||
else:
|
||||
self.clear_frames = min(self.clear_frames + 1, STEER_FAULT_RECOVERY_FRAMES)
|
||||
return self.clear_frames == STEER_FAULT_RECOVERY_FRAMES
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from iqpilot.selfdrive.controls.steering_fault_recovery import STEER_FAULT_RECOVERY_FRAMES, SteeringFaultRecovery
|
||||
|
||||
|
||||
def test_steering_fault_recovery_starts_ready():
|
||||
recovery = SteeringFaultRecovery()
|
||||
assert recovery.update(False, False)
|
||||
|
||||
|
||||
def test_temporary_fault_requires_continuous_clear_interval():
|
||||
recovery = SteeringFaultRecovery()
|
||||
assert not recovery.update(True, False)
|
||||
for _ in range(STEER_FAULT_RECOVERY_FRAMES - 1):
|
||||
assert not recovery.update(False, False)
|
||||
assert recovery.update(False, False)
|
||||
|
||||
|
||||
def test_repeated_fault_restarts_recovery_interval():
|
||||
recovery = SteeringFaultRecovery()
|
||||
assert not recovery.update(False, True)
|
||||
for _ in range(STEER_FAULT_RECOVERY_FRAMES - 1):
|
||||
assert not recovery.update(False, False)
|
||||
assert not recovery.update(True, False)
|
||||
for _ in range(STEER_FAULT_RECOVERY_FRAMES - 1):
|
||||
assert not recovery.update(False, False)
|
||||
assert recovery.update(False, False)
|
||||
@@ -109,6 +109,13 @@ def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_t
|
||||
def main():
|
||||
config_realtime_process(7, 5)
|
||||
|
||||
# Set in the child, not at import: manager preimports every process module in the parent,
|
||||
# so an import-time write lands in one shared env that all children inherit (and setdefault
|
||||
# in a child is then a guaranteed no-op). tinygrad reads this lazily at QCOMDevice init.
|
||||
# KGSL: lower value = higher priority. DM has no 50ms deadline; at the driving contexts'
|
||||
# default 8 its kernels interleave with the warp and blow its submit tail 16ms -> 72ms p90.
|
||||
os.environ['QCOM_PRIORITY'] = os.getenv('DMON_QCOM_PRIORITY', '12')
|
||||
|
||||
cl_context = CLContext()
|
||||
model = ModelState(cl_context)
|
||||
cloudlog.warning("models loaded, dmonitoringmodeld starting")
|
||||
|
||||
9
iqpilot/selfdrive/iqmodeld/big_catalog.py
Normal file
9
iqpilot/selfdrive/iqmodeld/big_catalog.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
||||
|
||||
try:
|
||||
load_private_module(__name__, "iqpilot_private.models.big_catalog")
|
||||
except ProprietaryModuleMissing:
|
||||
from iqpilot.models_private_src.big_catalog import *
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -471,7 +472,7 @@ class FrameDropMeter:
|
||||
|
||||
|
||||
class InferenceDaemon:
|
||||
def __init__(self, demo: bool = False):
|
||||
def __init__(self, demo: bool = False, channel_path: str | None = None):
|
||||
cloudlog.warning("iqmodeld init")
|
||||
sentry.set_tag("daemon", PROCESS_NAME)
|
||||
cloudlog.bind(daemon=PROCESS_NAME)
|
||||
@@ -485,8 +486,15 @@ class InferenceDaemon:
|
||||
self._meta_layout = select_meta_layout()
|
||||
cloudlog.warning("models loaded, iqmodeld starting")
|
||||
|
||||
self._channel = None
|
||||
if channel_path is not None:
|
||||
from iqpilot.selfdrive.iqmodeld.model_channel import ModelChannel
|
||||
self._channel = ModelChannel(channel_path, create=True)
|
||||
|
||||
self._cameras = CameraIngress(self._gpu)
|
||||
self._pub = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "iqDriveModelData", "iqPerfTrace"])
|
||||
pub_services = ["iqPerfTrace"] if self._channel is not None else [
|
||||
"modelV2", "drivingModelData", "cameraOdometry", "iqDriveModelData", "iqPerfTrace"]
|
||||
self._pub = PubMaster(pub_services)
|
||||
self._sub = SubMaster([
|
||||
"deviceState", "carState", "roadCameraState", "extrinsicsCalibration",
|
||||
"driverMonitoringState", "carControl", "lateralDelay", "iqNavState", "radarState",
|
||||
@@ -513,6 +521,12 @@ class InferenceDaemon:
|
||||
def _refresh_tunables(self, tick: int) -> None:
|
||||
if tick % 60 != 0:
|
||||
return
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
big_enabled = self._params.get_bool("IQEmacEnabled") or egpu_selected(self._params)
|
||||
if big_enabled != (self._channel is not None):
|
||||
# publish mode is fixed at startup: staying up would fight the selector for modelV2
|
||||
cloudlog.warning("iqmodeld: big backend toggled, restarting to switch publish mode")
|
||||
sys.exit(0)
|
||||
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)
|
||||
@@ -600,6 +614,22 @@ class InferenceDaemon:
|
||||
live_calib_seen,
|
||||
)
|
||||
|
||||
if self._channel is not None:
|
||||
self._channel.write(main_stamp.frame_id, {
|
||||
"source": "small",
|
||||
"frame_id": main_stamp.frame_id,
|
||||
"timestamp_sof": int(main_stamp.timestamp_sof),
|
||||
"live_calib_seen": bool(live_calib_seen),
|
||||
"model_execution_time": float(execution_time),
|
||||
"msgs": {
|
||||
"modelV2": model_msg.to_bytes(),
|
||||
"drivingModelData": driving_msg.to_bytes(),
|
||||
"cameraOdometry": pose_msg.to_bytes(),
|
||||
"iqDriveModelData": iq_msg.to_bytes(),
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
self._pub.send("modelV2", model_msg)
|
||||
self._pub.send("drivingModelData", driving_msg)
|
||||
self._pub.send("cameraOdometry", pose_msg)
|
||||
@@ -695,8 +725,15 @@ class InferenceDaemon:
|
||||
tick += 1
|
||||
|
||||
|
||||
def main(demo: bool = False):
|
||||
InferenceDaemon(demo=demo).serve()
|
||||
def main(demo: bool = False, channel_path: str | None = "auto"):
|
||||
if channel_path == "auto":
|
||||
channel_path = None
|
||||
params = Params()
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
if params.get_bool("IQEmacEnabled") or egpu_selected(params):
|
||||
from iqpilot.selfdrive.iqmodeld.model_channel import SMALL_CHANNEL
|
||||
channel_path = SMALL_CHANNEL
|
||||
InferenceDaemon(demo=demo, channel_path=channel_path).serve()
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
46
iqpilot/selfdrive/iqmodeld/driving_action.py
Normal file
46
iqpilot/selfdrive/iqmodeld/driving_action.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.cereal import log
|
||||
|
||||
from iqpilot.selfdrive.controls.lib.drive_helpers import smooth_value
|
||||
|
||||
LAT_SMOOTH_SECONDS = 0.0
|
||||
LONG_SMOOTH_SECONDS = 0.3
|
||||
MIN_LAT_CONTROL_SPEED = 0.3
|
||||
DESIRE_LEN = 8
|
||||
|
||||
|
||||
def get_action_from_model(outputs: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action,
|
||||
v_ego: float, lat_action_t: float, long_action_t: float,
|
||||
lat_smooth_seconds: float | None = None) -> log.ModelDataV2.Action:
|
||||
if "action" in outputs:
|
||||
desired_accel = float(outputs["action"][0, 1])
|
||||
desired_curvature = float(outputs["action"][0, 0]) / (max(1.0, v_ego)) ** 2
|
||||
should_stop = bool(v_ego < 0.3 and desired_accel < 0.1)
|
||||
else:
|
||||
from iqpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, get_curvature_from_plan
|
||||
from iqpilot.selfdrive.iqmodeld.config import ModelConstants, Plan
|
||||
plan = outputs["plan"][0]
|
||||
desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0],
|
||||
plan[:, Plan.ACCELERATION][:, 0],
|
||||
ModelConstants.T_IDXS,
|
||||
action_t=long_action_t)
|
||||
desired_curvature = get_curvature_from_plan(plan[:, Plan.T_FROM_CURRENT_EULER][:, 2],
|
||||
plan[:, Plan.ORIENTATION_RATE][:, 2],
|
||||
ModelConstants.T_IDXS, v_ego, lat_action_t)
|
||||
desired_accel, should_stop = float(desired_accel), bool(should_stop)
|
||||
desired_curvature = float(desired_curvature)
|
||||
desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, LONG_SMOOTH_SECONDS)
|
||||
if v_ego > MIN_LAT_CONTROL_SPEED:
|
||||
lat_smooth = LAT_SMOOTH_SECONDS if lat_smooth_seconds is None else lat_smooth_seconds
|
||||
desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, lat_smooth)
|
||||
else:
|
||||
desired_curvature = prev_action.desiredCurvature
|
||||
return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature),
|
||||
desiredAcceleration=float(desired_accel),
|
||||
shouldStop=should_stop)
|
||||
158
iqpilot/selfdrive/iqmodeld/egpu_helpers.py
Normal file
158
iqpilot/selfdrive/iqmodeld/egpu_helpers.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.system.hardware.usb import egpu_dock_ready
|
||||
|
||||
USB_SYSFS_ROOT = "/sys/bus/usb/devices"
|
||||
|
||||
COMMA_LFS_BATCH_URL = "https://gitlab.com/commaai/openpilot-lfs.git/info/lfs/objects/batch"
|
||||
|
||||
DOWNLOAD_CHUNK = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def usbgpu_present(sysfs_root: str = USB_SYSFS_ROOT) -> bool:
|
||||
return egpu_dock_ready(Path(sysfs_root))
|
||||
|
||||
|
||||
def egpu_present_consented(params, sysfs_root: str = USB_SYSFS_ROOT) -> bool:
|
||||
try:
|
||||
if params is not None and params.get_bool("IQEgpuDisabled"):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return usbgpu_present(sysfs_root)
|
||||
|
||||
|
||||
def egpu_selected(params, sysfs_root: str = USB_SYSFS_ROOT) -> bool:
|
||||
try:
|
||||
if params is not None and params.get_bool("IQEgpuDisabled"):
|
||||
return False
|
||||
if params is not None and params.get_bool("IQEgpuEnabled"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return usbgpu_present(sysfs_root)
|
||||
|
||||
|
||||
def resolve_backend(emac_enabled: bool, egpu_enabled: bool, egpu_present: bool = False) -> str | None:
|
||||
if egpu_present:
|
||||
return "egpu"
|
||||
if emac_enabled:
|
||||
return "emac"
|
||||
if egpu_enabled:
|
||||
return "egpu"
|
||||
return None
|
||||
|
||||
|
||||
def egpu_pkl_path(meta: dict) -> str:
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
return os.path.join(Paths.model_root(), f"egpu_{meta['key']}_{meta['sha256'][:8]}_amd_tinygrad.pkl")
|
||||
|
||||
|
||||
def onnx_cache_path(meta: dict) -> str:
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
return os.path.join(Paths.model_root(), f"{meta['model_name']}_{meta['sha256'][:8]}.onnx")
|
||||
|
||||
|
||||
def _sha256_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(DOWNLOAD_CHUNK):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def quarantine_artifact(path: str, why: str) -> None:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.replace(path, path + ".unusable")
|
||||
except OSError:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def local_onnx(meta: dict) -> str | None:
|
||||
path = onnx_cache_path(meta)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
size = int(meta.get("download", {}).get("size", 0))
|
||||
if size and os.path.getsize(path) != size:
|
||||
quarantine_artifact(path, "onnx size mismatch")
|
||||
return None
|
||||
if _sha256_file(path) != meta["sha256"]:
|
||||
quarantine_artifact(path, "onnx sha256 mismatch")
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def resolve_download_url(download_url: str, sha256: str, size: int, timeout: float = 30.0) -> str:
|
||||
if download_url.startswith("commalfs:"):
|
||||
oid = download_url.split(":", 1)[1]
|
||||
body = json.dumps({"operation": "download", "transfers": ["basic"],
|
||||
"objects": [{"oid": oid, "size": size}]}).encode()
|
||||
req = urllib.request.Request(COMMA_LFS_BATCH_URL, data=body, headers={
|
||||
"Accept": "application/vnd.git-lfs+json", "Content-Type": "application/vnd.git-lfs+json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
d = json.load(r)
|
||||
return d["objects"][0]["actions"]["download"]["href"]
|
||||
return download_url
|
||||
|
||||
|
||||
def download_onnx(meta: dict, progress_cb=None) -> str:
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import download_descriptor
|
||||
download_url, size = download_descriptor(meta)
|
||||
if not download_url:
|
||||
raise RuntimeError(f"model {meta['key']} has no download source; stage the onnx at {onnx_cache_path(meta)}")
|
||||
|
||||
url = resolve_download_url(download_url, meta["sha256"], size)
|
||||
path = onnx_cache_path(meta)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".part"
|
||||
digest = hashlib.sha256()
|
||||
got = 0
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
|
||||
while chunk := r.read(DOWNLOAD_CHUNK):
|
||||
f.write(chunk)
|
||||
digest.update(chunk)
|
||||
got += len(chunk)
|
||||
if progress_cb is not None and size:
|
||||
progress_cb(got / size)
|
||||
if size and got != size:
|
||||
os.remove(tmp)
|
||||
raise RuntimeError(f"onnx download truncated: {got}/{size} bytes")
|
||||
if digest.hexdigest() != meta["sha256"]:
|
||||
os.remove(tmp)
|
||||
raise RuntimeError(f"onnx sha256 mismatch for {meta['key']}")
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
def patch_tinygrad_fetch_fw() -> None:
|
||||
import pathlib
|
||||
|
||||
import zstandard
|
||||
from tinygrad import helpers
|
||||
if getattr(helpers.fetch_fw, "_iq_patched", False):
|
||||
return
|
||||
_orig = helpers.fetch_fw
|
||||
|
||||
def fetch_fw(path, name, sha256):
|
||||
p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst")
|
||||
if p.is_file():
|
||||
blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read()
|
||||
if hashlib.sha256(blob).hexdigest() == sha256:
|
||||
return blob
|
||||
return _orig(path, name, sha256)
|
||||
|
||||
fetch_fw._iq_patched = True
|
||||
helpers.fetch_fw = fetch_fw
|
||||
9
iqpilot/selfdrive/iqmodeld/egpu_model.py
Normal file
9
iqpilot/selfdrive/iqmodeld/egpu_model.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
||||
|
||||
try:
|
||||
load_private_module(__name__, "iqpilot_private.models.egpu_model")
|
||||
except ProprietaryModuleMissing:
|
||||
from iqpilot.models_private_src.egpu_model import *
|
||||
47
iqpilot/selfdrive/iqmodeld/egpu_pipeline.py
Normal file
47
iqpilot/selfdrive/iqmodeld/egpu_pipeline.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import MODEL_INPUT_SPEC, TemporalInputState, spec_from_meta
|
||||
|
||||
|
||||
class EgpuPipelineError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class EgpuPipeline:
|
||||
|
||||
def __init__(self, meta: dict, infer_fn):
|
||||
if meta.get("split"):
|
||||
raise EgpuPipelineError(f"model {meta['key']} is a split model; eGPU v1 runs fused models only")
|
||||
self.meta = meta
|
||||
self.infer_fn = infer_fn
|
||||
self.state = TemporalInputState(meta["frame_skip"], spec_from_meta(meta) or MODEL_INPUT_SPEC)
|
||||
self.hidden_slice = meta["output_slices"]["hidden_state"]
|
||||
self.output_len = int(meta["output_len"])
|
||||
|
||||
def run(self, warped: np.ndarray, desire_vec: np.ndarray, traffic_convention: np.ndarray,
|
||||
action_t: np.ndarray) -> np.ndarray:
|
||||
inputs = self.state.push_and_materialize(warped, desire_vec, traffic_convention, action_t)
|
||||
out = np.asarray(self.infer_fn(inputs), dtype=np.float32).reshape(-1)
|
||||
if out.shape[0] != self.output_len:
|
||||
raise EgpuPipelineError(f"eGPU output length {out.shape[0]} != {self.output_len}")
|
||||
if not np.isfinite(out).all():
|
||||
raise EgpuPipelineError("eGPU output contains non-finite values")
|
||||
self.state.note_hidden_state(out, self.hidden_slice)
|
||||
return out
|
||||
|
||||
|
||||
def make_big_channel_payload(frame_id: int, live_calib_seen: bool, execution_time: float,
|
||||
egpu_exec_ms: float, msgs: dict[str, bytes]) -> dict:
|
||||
return {
|
||||
"source": "egpu_big",
|
||||
"frame_id": int(frame_id),
|
||||
"live_calib_seen": bool(live_calib_seen),
|
||||
"model_execution_time": float(execution_time),
|
||||
"egpu_exec_ms": float(egpu_exec_ms),
|
||||
"msgs": msgs,
|
||||
}
|
||||
106
iqpilot/selfdrive/iqmodeld/egpu_telemetry.py
Normal file
106
iqpilot/selfdrive/iqmodeld/egpu_telemetry.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from iqpilot.cereal import messaging
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
|
||||
METRICS_REFRESH_EVERY = 100
|
||||
|
||||
|
||||
class EgpuDockTelemetry:
|
||||
|
||||
def __init__(self, pm, big: bool):
|
||||
self.pm = pm
|
||||
self.big = big
|
||||
self.valid = True
|
||||
self.sends = 0
|
||||
self.metrics: dict[str, float] = {}
|
||||
self._power_limit: int | None = None
|
||||
self._asm_usb = None
|
||||
|
||||
def _device(self):
|
||||
from tinygrad.device import Device
|
||||
return Device
|
||||
|
||||
def _open_asm_usb(self):
|
||||
import usb1
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_USB_IDS
|
||||
context = usb1.USBContext()
|
||||
for vendor_id, product_id in EGPU_DOCK_USB_IDS:
|
||||
handle = context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)
|
||||
if handle is not None:
|
||||
return handle
|
||||
context.close()
|
||||
return None
|
||||
|
||||
def _read_ina(self):
|
||||
Device = self._device()
|
||||
if "AMD" in Device._opened_devices and self._asm_usb is None:
|
||||
try:
|
||||
raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5)
|
||||
return struct.unpack("<Hh?", bytes(raw))
|
||||
except Exception:
|
||||
pass
|
||||
if self._asm_usb is None:
|
||||
self._asm_usb = self._open_asm_usb()
|
||||
if self._asm_usb is None:
|
||||
raise RuntimeError("no egpu ASM usb handle")
|
||||
try:
|
||||
raw = self._asm_usb.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100)
|
||||
except Exception:
|
||||
self._asm_usb = None
|
||||
raise
|
||||
return struct.unpack("<Hh?", bytes(raw))
|
||||
|
||||
def power_limit(self, smu) -> int:
|
||||
if self._power_limit is None:
|
||||
self._power_limit = smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100)
|
||||
return self._power_limit
|
||||
|
||||
def send(self) -> None:
|
||||
Device = self._device()
|
||||
msg = messaging.new_message("egpuDockState")
|
||||
state = msg.egpuDockState
|
||||
self.sends += 1
|
||||
|
||||
if self.big and "AMD" in Device._opened_devices and self.sends % METRICS_REFRESH_EVERY == 1:
|
||||
try:
|
||||
smu = Device["AMD"].iface.dev_impl.smu
|
||||
smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100)
|
||||
metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics
|
||||
self.metrics = {"tempC": metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT],
|
||||
"memoryTempC": metrics.AvgTemperature[smu.smu_mod.TEMP_MEM],
|
||||
"powerDrawW": metrics.AverageSocketPower,
|
||||
"powerLimitW": self.power_limit(smu),
|
||||
"gpuUsagePercent": metrics.AverageGfxActivity,
|
||||
"gpuClockMhz": metrics.AverageGfxclkFrequencyPostDs,
|
||||
"fanSpeedRpm": metrics.AvgFanRpm}
|
||||
self.valid = True
|
||||
except Exception:
|
||||
if self.valid:
|
||||
cloudlog.exception("egpu dock state read failed")
|
||||
self.valid = False
|
||||
self.metrics.clear()
|
||||
|
||||
if self.big:
|
||||
for k, v in self.metrics.items():
|
||||
setattr(state, k, v)
|
||||
|
||||
asm_valid = False
|
||||
try:
|
||||
state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina()
|
||||
asm_valid = True
|
||||
except Exception:
|
||||
pass
|
||||
if "AMD" in Device._opened_devices:
|
||||
try:
|
||||
state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg.valid = asm_valid and (not self.big or self.valid)
|
||||
self.pm.send("egpuDockState", msg)
|
||||
10
iqpilot/selfdrive/iqmodeld/emac_input_state.py
Normal file
10
iqpilot/selfdrive/iqmodeld/emac_input_state.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import (
|
||||
SplitTemporalState as SplitInputState,
|
||||
TemporalInputState as EmacInputState,
|
||||
)
|
||||
|
||||
__all__ = ["EmacInputState", "SplitInputState"]
|
||||
9
iqpilot/selfdrive/iqmodeld/emac_model_meta.py
Normal file
9
iqpilot/selfdrive/iqmodeld/emac_model_meta.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
||||
|
||||
try:
|
||||
load_private_module(__name__, "iqpilot_private.models.emac_model_meta")
|
||||
except ProprietaryModuleMissing:
|
||||
from iqpilot.models_private_src.emac_model_meta import *
|
||||
377
iqpilot/selfdrive/iqmodeld/iqegpumodeld.py
Normal file
377
iqpilot/selfdrive/iqmodeld/iqegpumodeld.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from iqpilot.system.hardware import TICI
|
||||
|
||||
os.environ.setdefault("GMMU", "0")
|
||||
if TICI:
|
||||
os.environ.setdefault("DEV", "QCOM")
|
||||
else:
|
||||
os.environ.setdefault("DEV", "CPU")
|
||||
|
||||
import numpy as np
|
||||
from setproctitle import setproctitle
|
||||
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.cereal import car, log
|
||||
from iqpilot.cereal.messaging import SubMaster
|
||||
from iqpilot.cereal.services import SERVICE_LIST
|
||||
from iqdbc.car.car_helpers import get_demo_car_params
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.realtime import DT_MDL
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.controls.lib.desire_helper import DesireHelper
|
||||
from iqpilot.system import sentry
|
||||
|
||||
from iqpilot.common.steer_delay import lateral_action_delay
|
||||
from iqpilot.selfdrive.iqmodeld.daemon import CalibrationAtlas, CameraIngress, FrameDropMeter
|
||||
from iqpilot.selfdrive.iqmodeld.driving_action import (
|
||||
DESIRE_LEN, LAT_SMOOTH_SECONDS, LONG_SMOOTH_SECONDS, get_action_from_model,
|
||||
)
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import (
|
||||
download_onnx, egpu_pkl_path, egpu_present_consented, egpu_selected, local_onnx, patch_tinygrad_fetch_fw,
|
||||
quarantine_artifact, resolve_backend, usbgpu_present,
|
||||
)
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_pipeline import EgpuPipeline, EgpuPipelineError, make_big_channel_payload
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_telemetry import EgpuDockTelemetry
|
||||
from iqpilot.selfdrive.iqmodeld.messaging import DrivePacketMemory, populate_drive_messages, populate_odometry_message
|
||||
from iqpilot.selfdrive.iqmodeld.metadata import Meta20hz
|
||||
from iqpilot.selfdrive.iqmodeld.model_channel import BIG_CHANNEL, ModelChannel
|
||||
from iqpilot.selfdrive.iqmodeld.model_warp import FrameWarp
|
||||
from iqpilot.selfdrive.iqmodeld.parser import PhaseParser
|
||||
|
||||
PROCESS_NAME = "iqpilot.selfdrive.iqmodeld.iqegpumodeld"
|
||||
|
||||
PRESENCE_POLL_S = 5.0
|
||||
COMPILE_TIMEOUT_S = 3600
|
||||
LINK_UP_TIMEOUT_S = 10.0
|
||||
SETUP_RETRY_BASE_S = 3.0
|
||||
SETUP_RETRY_MAX_S = 30.0
|
||||
|
||||
|
||||
def park(reason: str) -> None:
|
||||
cloudlog.warning(f"iqegpumodeld parked: {reason}")
|
||||
params = Params()
|
||||
params.put_bool("UsbGpuFailed", True)
|
||||
params.put("UsbGpuLastError", reason[:512])
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def _wait_for_egpu(params: Params) -> None:
|
||||
while not usbgpu_present():
|
||||
params.put_bool("UsbGpuPresent", False)
|
||||
time.sleep(PRESENCE_POLL_S)
|
||||
params.put_bool("UsbGpuPresent", True)
|
||||
try:
|
||||
from iqpilot.system.hardware.egpu_dock.flash import link_up
|
||||
except Exception:
|
||||
return
|
||||
deadline = time.monotonic() + LINK_UP_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if link_up():
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _compile_in_subprocess(meta: dict, onnx_path: str, pkl_path: str) -> None:
|
||||
cmd = [sys.executable, "-m", "iqpilot.selfdrive.iqmodeld.tools.compile_egpu_model",
|
||||
"--model", meta["key"], "--onnx", onnx_path, "--output", pkl_path]
|
||||
compile_env = {**os.environ, "DEV": "USB+AMD:LLVM", "FLOAT16": "1",
|
||||
"JIT_BATCH_SIZE": "0", "GMMU": "0"}
|
||||
proc = subprocess.run(cmd, timeout=COMPILE_TIMEOUT_S, capture_output=True, text=True,
|
||||
env=compile_env, preexec_fn=lambda: os.nice(20))
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()[-800:]
|
||||
raise RuntimeError(f"eGPU model compile failed (rc={proc.returncode}): {tail}")
|
||||
|
||||
|
||||
def _ensure_artifact(params: Params, meta: dict) -> str:
|
||||
pkl_path = egpu_pkl_path(meta)
|
||||
if os.path.isfile(pkl_path):
|
||||
return pkl_path
|
||||
|
||||
params.put_bool("UsbGpuCompiled", False)
|
||||
onnx_path = local_onnx(meta)
|
||||
if onnx_path is None:
|
||||
params.put("UsbGpuSetupProgress", "0.0")
|
||||
cloudlog.warning(f"iqegpumodeld downloading {meta['key']} onnx ({meta.get('download', {}).get('size', 0) / 1e6:.0f}MB)")
|
||||
last = [-1.0]
|
||||
|
||||
def _prog(p: float) -> None:
|
||||
if p - last[0] >= 0.02 or p >= 1.0:
|
||||
last[0] = p
|
||||
params.put("UsbGpuSetupProgress", f"{p:.3f}")
|
||||
|
||||
onnx_path = download_onnx(meta, progress_cb=_prog)
|
||||
|
||||
cloudlog.warning(f"iqegpumodeld compiling {meta['key']} for USB-AMD (one-time, can take minutes)")
|
||||
_compile_in_subprocess(meta, onnx_path, pkl_path)
|
||||
cloudlog.warning(f"iqegpumodeld compiled -> {pkl_path}")
|
||||
return pkl_path
|
||||
|
||||
|
||||
def _load_infer_fn(pkl_path: str, meta: dict):
|
||||
patch_tinygrad_fetch_fw()
|
||||
from tinygrad.tensor import Tensor
|
||||
|
||||
with open(pkl_path, "rb") as f:
|
||||
bundle = pickle.load(f)
|
||||
if bundle.get("model_sha256") != meta["sha256"]:
|
||||
quarantine_artifact(pkl_path, "pkl model sha mismatch")
|
||||
raise RuntimeError(f"artifact model sha {bundle.get('model_sha256')} != {meta['sha256']}")
|
||||
if int(bundle.get("output_len", -1)) != int(meta["output_len"]):
|
||||
quarantine_artifact(pkl_path, "pkl output_len mismatch")
|
||||
raise RuntimeError(f"artifact output_len {bundle.get('output_len')} != {meta['output_len']}")
|
||||
jit = bundle["run_model"]
|
||||
input_dev = bundle.get("input_device", "AMD")
|
||||
input_spec = bundle["input_spec"]
|
||||
|
||||
def infer(inputs: dict[str, np.ndarray]) -> np.ndarray:
|
||||
tensors = {name: Tensor(np.ascontiguousarray(inputs[name]), device=input_dev).realize()
|
||||
for name in input_spec}
|
||||
out, = jit(**tensors)
|
||||
return out.numpy().reshape(-1)
|
||||
|
||||
return infer, input_spec
|
||||
|
||||
|
||||
def _warmup(infer_fn, input_spec: dict, output_len: int) -> float:
|
||||
zeros = {name: np.zeros(shape, dtype=dtype) for name, (shape, dtype) in input_spec.items()}
|
||||
t0 = time.perf_counter()
|
||||
out = infer_fn(zeros)
|
||||
dt = time.perf_counter() - t0
|
||||
if out.shape[0] != output_len or not np.isfinite(out).all():
|
||||
raise RuntimeError(f"warmup produced invalid output (len={out.shape[0]})")
|
||||
return dt
|
||||
|
||||
|
||||
def main(demo: bool = False) -> None:
|
||||
cloudlog.warning("iqegpumodeld init")
|
||||
sentry.set_tag("daemon", PROCESS_NAME)
|
||||
cloudlog.bind(daemon=PROCESS_NAME)
|
||||
setproctitle(PROCESS_NAME)
|
||||
try:
|
||||
os.sched_setaffinity(0, {4, 5, 6})
|
||||
os.nice(-10)
|
||||
except OSError as e:
|
||||
cloudlog.warning(f"iqegpumodeld affinity/nice failed ({e}); continuing at defaults")
|
||||
|
||||
params = Params()
|
||||
backend = resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), egpu_present_consented(params))
|
||||
if backend != "egpu":
|
||||
park(f"backend resolution is {backend!r}, not egpu; refusing to own the big channel")
|
||||
|
||||
channel = ModelChannel(BIG_CHANNEL, create=True)
|
||||
|
||||
cloudlog.warning("iqegpumodeld waiting for camerad")
|
||||
cameras = CameraIngress(None)
|
||||
layout = cameras.layout
|
||||
|
||||
_wait_for_egpu(params)
|
||||
params.put_bool("UsbGpuLoading", True)
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
meta = resolve_egpu_model(params)
|
||||
if meta is None:
|
||||
raise RuntimeError("selected big model is not in the catalog; check connectivity or pick another model")
|
||||
if meta.get("split"):
|
||||
params.put_bool("UsbGpuLoading", False)
|
||||
park(f"model {meta['key']} needs the Mac backend; the eGPU runs fused models only")
|
||||
warp = FrameWarp(cameras._primary.width, cameras._primary.height, meta["frame_skip"])
|
||||
pkl_path = _ensure_artifact(params, meta)
|
||||
infer_fn, input_spec = _load_infer_fn(pkl_path, meta)
|
||||
warm_s = _warmup(infer_fn, input_spec, meta["output_len"])
|
||||
break
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
params.put("UsbGpuLastError", str(e)[:512])
|
||||
cloudlog.warning(f"iqegpumodeld setup attempt {attempt} failed: {e}; retrying")
|
||||
if not usbgpu_present():
|
||||
_wait_for_egpu(params)
|
||||
time.sleep(min(SETUP_RETRY_MAX_S, SETUP_RETRY_BASE_S * attempt))
|
||||
|
||||
params.put_bool("UsbGpuLoading", False)
|
||||
params.put_bool("UsbGpuCompiled", True)
|
||||
params.put("UsbGpuSetupProgress", "1.0")
|
||||
cloudlog.warning(f"iqegpumodeld model: {meta['key']} ({meta['model_name']})")
|
||||
cloudlog.warning(f"iqegpumodeld model up (warmup {warm_s * 1e3:.0f}ms)")
|
||||
|
||||
pipeline = EgpuPipeline(meta, infer_fn)
|
||||
telemetry_pm = messaging.PubMaster(["egpuDockState"])
|
||||
telemetry = EgpuDockTelemetry(telemetry_pm, big=True)
|
||||
telemetry_every = max(1, round((1.0 / DT_MDL) / SERVICE_LIST["egpuDockState"].frequency))
|
||||
|
||||
sub = SubMaster(["deviceState", "carState", "roadCameraState", "extrinsicsCalibration",
|
||||
"driverMonitoringState", "carControl", "lateralDelay", "iqNavState", "radarState"])
|
||||
if demo:
|
||||
CP = get_demo_car_params()
|
||||
else:
|
||||
CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
|
||||
long_delay = CP.longitudinalActuatorDelay + LONG_SMOOTH_SECONDS
|
||||
|
||||
parser = PhaseParser()
|
||||
memory = DrivePacketMemory()
|
||||
desire_logic = DesireHelper()
|
||||
frame_meter = FrameDropMeter(20.0)
|
||||
warps = CalibrationAtlas()
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
slices = {k: v for k, v in meta["output_slices"].items() if k != "pad"}
|
||||
|
||||
produced = 0
|
||||
stats: dict[str, list[float]] = {k: [] for k in ("pull", "warp", "infer", "publish", "loop")}
|
||||
iter_count = 0
|
||||
skip_count = 0
|
||||
last_pulled_fid = -1
|
||||
last_frame_mono = time.monotonic()
|
||||
t_loop = time.perf_counter()
|
||||
cloudlog.warning("iqegpumodeld starting")
|
||||
|
||||
while True:
|
||||
frame_pair = cameras.pull()
|
||||
t_pull = time.perf_counter()
|
||||
if frame_pair is None:
|
||||
if time.monotonic() - last_frame_mono > 2.0:
|
||||
cloudlog.warning("iqegpumodeld camera stream silent >2s; reconnecting VisionIPC")
|
||||
cameras = CameraIngress(None)
|
||||
last_frame_mono = time.monotonic()
|
||||
continue
|
||||
last_frame_mono = time.monotonic()
|
||||
main_buf, extra_buf, main_stamp, extra_stamp = frame_pair
|
||||
|
||||
stats["pull"].append(t_pull - t_loop)
|
||||
stats["loop"].append(time.perf_counter() - t_loop)
|
||||
t_loop = time.perf_counter()
|
||||
if last_pulled_fid >= 0 and main_stamp.frame_id > last_pulled_fid + 1:
|
||||
skip_count += main_stamp.frame_id - last_pulled_fid - 1
|
||||
last_pulled_fid = main_stamp.frame_id
|
||||
iter_count += 1
|
||||
if iter_count % 200 == 0:
|
||||
pcts = {k: {"p50": round(sorted(v)[len(v) // 2] * 1e3, 1),
|
||||
"p90": round(sorted(v)[int(len(v) * 0.9)] * 1e3, 1)}
|
||||
for k, v in stats.items() if v}
|
||||
cloudlog.event("iqegpu_stats", **pcts, cam_skips=skip_count, window=iter_count)
|
||||
msg = " ".join(f"{k}=p50:{v['p50']:.0f}/p90:{v['p90']:.0f}ms" for k, v in pcts.items())
|
||||
cloudlog.warning(f"iqegpumodeld stages: {msg} cam_skips={skip_count} over {iter_count}")
|
||||
for v in stats.values():
|
||||
v.clear()
|
||||
skip_count = 0
|
||||
|
||||
sub.update(0)
|
||||
|
||||
v_ego = max(sub["carState"].vEgo, 0.0)
|
||||
lat_delay = lateral_action_delay(params, CP, sub["lateralDelay"].lateralDelay) + LAT_SMOOTH_SECONDS
|
||||
main_tfm, extra_tfm, live_calib_seen = warps.refresh(sub, layout.main_is_wide, layout.dual_camera)
|
||||
dropped_frames, frame_drop_ratio, _ = frame_meter.sample(main_stamp.frame_id)
|
||||
|
||||
traffic = np.zeros(2, dtype=np.float32)
|
||||
traffic[int(sub["driverMonitoringState"].isRHD)] = 1
|
||||
desire_vec = np.zeros(DESIRE_LEN, dtype=np.float32)
|
||||
if 0 <= desire_logic.desire < DESIRE_LEN:
|
||||
desire_vec[desire_logic.desire] = 1
|
||||
|
||||
frame_delay = DT_MDL
|
||||
action_delay = DT_MDL / 2
|
||||
lat_action_t = lat_delay + frame_delay + action_delay
|
||||
long_action_t = long_delay + frame_delay + action_delay
|
||||
action_t = np.array([lat_action_t, long_action_t], dtype=np.float32)
|
||||
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
warped = warp.run(main_buf, extra_buf, main_tfm, extra_tfm)
|
||||
except Exception as e:
|
||||
park(f"warp run failed: {e}")
|
||||
t_warp = time.perf_counter()
|
||||
stats["warp"].append(t_warp - started_at)
|
||||
|
||||
try:
|
||||
output = pipeline.run(warped, desire_vec, traffic, action_t)
|
||||
except EgpuPipelineError as e:
|
||||
park(str(e))
|
||||
except Exception as e:
|
||||
park(f"eGPU inference failed: {e}")
|
||||
t_infer = time.perf_counter()
|
||||
stats["infer"].append(t_infer - t_warp)
|
||||
|
||||
execution_time = time.perf_counter() - started_at
|
||||
sliced = {k: output[np.newaxis, sl] for k, sl in slices.items()}
|
||||
outputs = parser.parse_vision_outputs(sliced)
|
||||
|
||||
action = get_action_from_model(outputs, prev_action, v_ego, float(lat_action_t), float(long_action_t),
|
||||
lat_smooth_seconds=meta.get("lat_smooth_seconds"))
|
||||
prev_action = action
|
||||
|
||||
model_msg = messaging.new_message("modelV2")
|
||||
driving_msg = messaging.new_message("drivingModelData")
|
||||
pose_msg = messaging.new_message("cameraOdometry")
|
||||
iq_msg = messaging.new_message("iqDriveModelData")
|
||||
|
||||
populate_drive_messages(
|
||||
driving_msg, model_msg, outputs, action, memory,
|
||||
main_stamp.frame_id, extra_stamp.frame_id, sub["roadCameraState"].frameId,
|
||||
frame_drop_ratio, main_stamp.timestamp_eof, execution_time,
|
||||
live_calib_seen, Meta20hz,
|
||||
)
|
||||
|
||||
model_msg.modelV2.big = True
|
||||
|
||||
desire_state = model_msg.modelV2.meta.desireState
|
||||
lane_change_prob = desire_state[log.Desire.laneChangeLeft] + desire_state[log.Desire.laneChangeRight]
|
||||
desire_logic.update(sub["carState"], sub["carControl"].latActive, lane_change_prob,
|
||||
sub["iqNavState"], model_msg.modelV2, sub["radarState"])
|
||||
model_msg.modelV2.meta.laneChangeState = desire_logic.lane_change_state
|
||||
model_msg.modelV2.meta.laneChangeDirection = desire_logic.lane_change_direction
|
||||
driving_msg.drivingModelData.meta.laneChangeState = desire_logic.lane_change_state
|
||||
driving_msg.drivingModelData.meta.laneChangeDirection = desire_logic.lane_change_direction
|
||||
iq_msg.iqDriveModelData.turnSignalDirection = desire_logic.lane_turn_direction
|
||||
|
||||
populate_odometry_message(pose_msg, outputs, main_stamp.frame_id, dropped_frames,
|
||||
main_stamp.timestamp_eof, live_calib_seen)
|
||||
|
||||
channel.write(main_stamp.frame_id, make_big_channel_payload(
|
||||
main_stamp.frame_id, live_calib_seen, execution_time, (t_infer - t_warp) * 1e3, {
|
||||
"modelV2": model_msg.to_bytes(),
|
||||
"drivingModelData": driving_msg.to_bytes(),
|
||||
"cameraOdometry": pose_msg.to_bytes(),
|
||||
"iqDriveModelData": iq_msg.to_bytes(),
|
||||
}))
|
||||
stats["publish"].append(time.perf_counter() - t_infer)
|
||||
produced += 1
|
||||
if produced == 1 or produced % 100 == 0:
|
||||
infer_ms = (t_infer - t_warp) * 1e3
|
||||
cloudlog.warning(f"iqegpumodeld producing: frame={main_stamp.frame_id} total={execution_time * 1e3:.0f}ms infer={infer_ms:.0f}ms count={produced}")
|
||||
|
||||
if produced % telemetry_every == 0:
|
||||
telemetry.send()
|
||||
|
||||
frame_meter.commit(main_stamp.frame_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--demo", action="store_true")
|
||||
args = ap.parse_args()
|
||||
main(demo=args.demo)
|
||||
except KeyboardInterrupt:
|
||||
cloudlog.warning("iqegpumodeld got SIGINT")
|
||||
except Exception:
|
||||
import traceback
|
||||
sentry.capture_exception()
|
||||
cloudlog.exception("iqegpumodeld crashed, parking")
|
||||
park(f"crashed: {traceback.format_exc(limit=8)}")
|
||||
58
iqpilot/selfdrive/iqmodeld/model_channel.py
Normal file
58
iqpilot/selfdrive/iqmodeld/model_channel.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mmap
|
||||
import os
|
||||
import pickle
|
||||
import struct
|
||||
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
|
||||
SMALL_CHANNEL = "/dev/shm/iqpilot_smallmodel"
|
||||
BIG_CHANNEL = "/dev/shm/iqpilot_bigmodel"
|
||||
SHM_SIZE = 8 * 1024 * 1024
|
||||
HEADER = struct.Struct("<QqQ")
|
||||
|
||||
|
||||
class ModelChannel:
|
||||
def __init__(self, path: str, create: bool):
|
||||
if create:
|
||||
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
os.ftruncate(fd, SHM_SIZE)
|
||||
else:
|
||||
fd = os.open(path, os.O_RDWR)
|
||||
self.mm = mmap.mmap(fd, SHM_SIZE)
|
||||
os.close(fd)
|
||||
if create:
|
||||
self.mm[:HEADER.size] = HEADER.pack(0, -1, 0)
|
||||
|
||||
def write(self, frame_id: int, payload: dict) -> None:
|
||||
data = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
if HEADER.size + len(data) > SHM_SIZE:
|
||||
cloudlog.error(f"model payload {len(data)} bytes exceeds shm {SHM_SIZE}, dropping frame {frame_id}")
|
||||
return
|
||||
seq = HEADER.unpack(self.mm[:HEADER.size])[0]
|
||||
HEADER.pack_into(self.mm, 0, seq + 1, frame_id, len(data))
|
||||
self.mm[HEADER.size:HEADER.size + len(data)] = data
|
||||
HEADER.pack_into(self.mm, 0, seq + 2, frame_id, len(data))
|
||||
|
||||
def peek_frame_id(self) -> int | None:
|
||||
seq, frame_id, length = HEADER.unpack(self.mm[:HEADER.size])
|
||||
if seq == 0 or seq % 2 != 0 or length == 0:
|
||||
return None
|
||||
return frame_id
|
||||
|
||||
def read(self) -> tuple[int, dict] | None:
|
||||
seq1, frame_id, length = HEADER.unpack(self.mm[:HEADER.size])
|
||||
if seq1 == 0 or seq1 % 2 != 0 or length == 0:
|
||||
return None
|
||||
data = bytes(self.mm[HEADER.size:HEADER.size + length])
|
||||
seq2 = HEADER.unpack(self.mm[:HEADER.size])[0]
|
||||
if seq1 != seq2:
|
||||
return None
|
||||
try:
|
||||
return frame_id, pickle.loads(data)
|
||||
except Exception:
|
||||
return None
|
||||
80
iqpilot/selfdrive/iqmodeld/model_warp.py
Normal file
80
iqpilot/selfdrive/iqmodeld/model_warp.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
|
||||
|
||||
def _load_bundle(pkl_path: str, cam_w: int, cam_h: int, frame_skip: int) -> dict:
|
||||
with open(pkl_path, "rb") as f:
|
||||
bundle = pickle.load(f)
|
||||
if bundle.get("frame_skip") != frame_skip:
|
||||
raise RuntimeError(f"frame_skip {bundle.get('frame_skip')} != {frame_skip}")
|
||||
if (cam_w, cam_h) not in bundle:
|
||||
raise RuntimeError(f"missing {cam_w}x{cam_h}; has {[k for k in bundle if isinstance(k, tuple)]}")
|
||||
_verify_selftest(bundle, cam_w, cam_h)
|
||||
return bundle
|
||||
|
||||
|
||||
def _verify_selftest(bundle: dict, cam_w: int, cam_h: int) -> None:
|
||||
want = bundle.get("selftest")
|
||||
if not want:
|
||||
raise RuntimeError("warp artifact predates the self-test; recompiling")
|
||||
from iqpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_warp import selftest_digest
|
||||
nv12_size = get_nv12_info(cam_w, cam_h)[3]
|
||||
got = selftest_digest(bundle[(cam_w, cam_h)], cam_w, cam_h, nv12_size)
|
||||
if got != want:
|
||||
raise RuntimeError(f"warp self-test {got[:12]} != {want[:12]}; artifact computes differently here")
|
||||
|
||||
|
||||
class FrameWarp:
|
||||
|
||||
def __init__(self, cam_w: int, cam_h: int, frame_skip: int):
|
||||
from tinygrad.tensor import Tensor
|
||||
|
||||
pkl_path = os.path.join(Paths.model_root(), f"emac_warp_{cam_w}x{cam_h}_tinygrad.pkl")
|
||||
bundle = None
|
||||
if os.path.isfile(pkl_path):
|
||||
try:
|
||||
bundle = _load_bundle(pkl_path, cam_w, cam_h, frame_skip)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"warp artifact unusable ({e}); discarding and recompiling")
|
||||
os.remove(pkl_path)
|
||||
if bundle is None:
|
||||
cloudlog.warning(f"warp artifact missing; compiling for {cam_w}x{cam_h} (one-time)")
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_warp import compile_warp
|
||||
compile_warp(cam_w, cam_h, pkl_path, frame_skip=frame_skip)
|
||||
cloudlog.warning(f"warp compiled -> {pkl_path}")
|
||||
bundle = _load_bundle(pkl_path, cam_w, cam_h, frame_skip)
|
||||
self._jit = bundle[(cam_w, cam_h)]
|
||||
|
||||
self._npy = {"tfm": np.zeros((3, 3), dtype=np.float32), "big_tfm": np.zeros((3, 3), dtype=np.float32)}
|
||||
self._tensors = {k: Tensor(v, device="NPY").realize() for k, v in self._npy.items()}
|
||||
self._blob_cache: dict[tuple[str, int], object] = {}
|
||||
self._Tensor = Tensor
|
||||
|
||||
def _frame_tensor(self, key: str, buf):
|
||||
from tinygrad.device import Device
|
||||
arr = np.frombuffer(buf.data, dtype=np.uint8)
|
||||
ck = (key, arr.ctypes.data)
|
||||
t = self._blob_cache.get(ck)
|
||||
if t is None:
|
||||
t = self._Tensor.from_blob(arr.ctypes.data, (arr.size,), dtype="uint8", device=Device.DEFAULT)
|
||||
self._blob_cache[ck] = t
|
||||
return t
|
||||
|
||||
def run(self, main_buf, extra_buf, main_tfm: np.ndarray, extra_tfm: np.ndarray) -> np.ndarray:
|
||||
self._npy["tfm"][:] = main_tfm
|
||||
self._npy["big_tfm"][:] = extra_tfm
|
||||
warped = self._jit(tfm=self._tensors["tfm"], big_tfm=self._tensors["big_tfm"],
|
||||
frame=self._frame_tensor("img", main_buf),
|
||||
big_frame=self._frame_tensor("big_img", extra_buf))
|
||||
return warped.numpy().astype(np.uint8, copy=False)
|
||||
373
iqpilot/selfdrive/iqmodeld/modeld_selector.py
Normal file
373
iqpilot/selfdrive/iqmodeld/modeld_selector.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from iqpilot.cereal.messaging import PubMaster, log_from_bytes
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.realtime import config_realtime_process
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.iqmodeld.model_channel import BIG_CHANNEL, SMALL_CHANNEL, ModelChannel
|
||||
|
||||
PROCESS_NAME = "iqpilot.selfdrive.iqmodeld.modeld_selector"
|
||||
|
||||
BIG_MODEL_DEADLINE = float(os.getenv("IQEMAC_BIG_DEADLINE_MS", "45")) / 1000.0
|
||||
BIG_MAX_LAG_FRAMES = int(os.getenv("IQEMAC_MAX_BIG_LAG_FRAMES", "6"))
|
||||
BIG_FUTURE_ACCEPT = int(os.getenv("IQEMAC_BIG_FUTURE_ACCEPT", "2"))
|
||||
BIG_ANCHOR_MS = float(os.getenv("IQEMAC_BIG_ANCHOR_MS", "90"))
|
||||
BIG_WAIT_FLOOR_S = 0.002
|
||||
BIG_WAIT_CEIL_S = float(os.getenv("IQEMAC_BIG_WAIT_CEIL_MS", "58")) / 1000.0
|
||||
BIG_MISS_LIMIT = int(os.getenv("IQEMAC_BIG_MISS_LIMIT", "80"))
|
||||
ACTIVATE_WINDOW = int(os.getenv("IQEMAC_ACTIVATE_WINDOW", "50"))
|
||||
ACTIVATE_FRAC = float(os.getenv("IQEMAC_ACTIVATE_FRAC", "0.7"))
|
||||
REARM_LIMIT = int(os.getenv("IQEMAC_REARM_LIMIT", "2"))
|
||||
MODEL_FREQ = 20.0
|
||||
WARMUP_FRAMES = 40
|
||||
STATUS_WINDOW = int(os.getenv("IQEMAC_STATUS_EVERY", "20"))
|
||||
|
||||
SELECTOR_SERVICES = ["modelV2", "drivingModelData", "cameraOdometry", "iqDriveModelData"]
|
||||
|
||||
EMAC_STATUS_KEYS = {
|
||||
"active": "MacModelActive", "failed": "MacModelFailed", "last_error": "MacModelLastError",
|
||||
"latency_ms": "MacModelLatencyMs", "status": "MacModelStatus",
|
||||
"reachable": "MacModelReachable", "progress": "MacModelDownloadProgress",
|
||||
}
|
||||
EGPU_STATUS_KEYS = {
|
||||
"active": "UsbGpuActive", "failed": "UsbGpuFailed", "last_error": "UsbGpuLastError",
|
||||
"latency_ms": "UsbGpuLatencyMs", "status": "UsbGpuStatus",
|
||||
"reachable": "UsbGpuPresent", "progress": "UsbGpuSetupProgress",
|
||||
}
|
||||
|
||||
|
||||
def backend_status_keys(emac_enabled: bool, egpu_enabled: bool, egpu_present: bool = False) -> dict[str, str]:
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import resolve_backend
|
||||
return EGPU_STATUS_KEYS if resolve_backend(emac_enabled, egpu_enabled, egpu_present) == "egpu" else EMAC_STATUS_KEYS
|
||||
|
||||
|
||||
def resolve_status_keys(params):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_present_consented, egpu_selected
|
||||
return backend_status_keys(params.get_bool("IQEmacEnabled"), egpu_selected(params), egpu_present_consented(params))
|
||||
|
||||
|
||||
def resolve_model_name(params, keys) -> str:
|
||||
if keys is EGPU_STATUS_KEYS:
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import DEFAULT_EGPU_MODEL, resolve_egpu_model
|
||||
resolved = resolve_egpu_model(params, allow_refresh=False)
|
||||
return resolved["key"] if resolved else DEFAULT_EGPU_MODEL
|
||||
name = params.get("IQEmacModel") or b"lebrowski"
|
||||
return name.decode() if isinstance(name, bytes) else name
|
||||
|
||||
|
||||
class AsyncParamWriter:
|
||||
|
||||
def __init__(self, params: Params):
|
||||
self._params = params
|
||||
self._pending: dict[str, object] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._event = threading.Event()
|
||||
threading.Thread(target=self._drain, daemon=True).start()
|
||||
|
||||
def put(self, key: str, value) -> None:
|
||||
with self._lock:
|
||||
self._pending[key] = value
|
||||
self._event.set()
|
||||
|
||||
def put_bool(self, key: str, value: bool) -> None:
|
||||
self.put(key, bool(value))
|
||||
|
||||
def _drain(self) -> None:
|
||||
while True:
|
||||
self._event.wait()
|
||||
self._event.clear()
|
||||
with self._lock:
|
||||
batch, self._pending = self._pending, {}
|
||||
for key, value in batch.items():
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
self._params.put_bool(key, value)
|
||||
else:
|
||||
self._params.put(key, value)
|
||||
except Exception:
|
||||
cloudlog.exception(f"async param write failed: {key}")
|
||||
|
||||
|
||||
def wait_for_big(big_channel, target: int, deadline: float, min_frame: int = -1,
|
||||
max_lag_frames: int = BIG_MAX_LAG_FRAMES) -> tuple[dict | None, int | None]:
|
||||
big_peek = None
|
||||
grab_at = deadline - 0.004
|
||||
while time.perf_counter() < deadline:
|
||||
bfid = big_channel.peek_frame_id()
|
||||
big_peek = bfid
|
||||
if bfid == target - 1 and time.perf_counter() < grab_at:
|
||||
time.sleep(0.0005)
|
||||
continue
|
||||
if bfid is not None and min_frame < bfid <= target + BIG_FUTURE_ACCEPT and target - bfid <= max_lag_frames:
|
||||
got = big_channel.read()
|
||||
if got is not None and got[0] == bfid:
|
||||
return got[1], big_peek
|
||||
break
|
||||
if bfid is None or bfid <= min_frame or bfid > target + BIG_FUTURE_ACCEPT or target - bfid > max_lag_frames:
|
||||
break
|
||||
time.sleep(0.0005)
|
||||
return None, big_peek
|
||||
|
||||
|
||||
class BigLatch:
|
||||
|
||||
def __init__(self, miss_limit: int = BIG_MISS_LIMIT, activate_window: int = ACTIVATE_WINDOW,
|
||||
activate_frac: float = ACTIVATE_FRAC, rearm_limit: int = REARM_LIMIT):
|
||||
self.miss_limit = miss_limit
|
||||
self.activate_window = activate_window
|
||||
self.activate_need = int(round(activate_window * activate_frac))
|
||||
self.rearm_limit = rearm_limit
|
||||
self.active = False
|
||||
self.done = False
|
||||
self._miss = 0
|
||||
self._window: deque[bool] = deque(maxlen=activate_window)
|
||||
self._retires = 0
|
||||
|
||||
def update(self, used_big: bool) -> tuple[bool, bool]:
|
||||
if self.done:
|
||||
return False, False
|
||||
if not self.active:
|
||||
self._window.append(used_big)
|
||||
if len(self._window) >= self.activate_window and sum(self._window) >= self.activate_need:
|
||||
self.active = True
|
||||
self._miss = 0
|
||||
self._window.clear()
|
||||
return True, False
|
||||
if used_big:
|
||||
self._miss = 0
|
||||
elif self.active:
|
||||
self._miss += 1
|
||||
if self._miss >= self.miss_limit:
|
||||
self.active = False
|
||||
self._miss = 0
|
||||
self._window.clear()
|
||||
self._retires += 1
|
||||
self.done = self._retires > self.rearm_limit
|
||||
return False, True
|
||||
return False, False
|
||||
|
||||
|
||||
def _patch_and_send(pm: PubMaster, payload: dict, frame_drop_perc: float, selector_dropped: int,
|
||||
target: int, source_lag: int, mismatch: bool | None = None) -> None:
|
||||
msgs = payload["msgs"]
|
||||
if mismatch is None:
|
||||
mismatch = source_lag > 0
|
||||
|
||||
model_msg = log_from_bytes(msgs["modelV2"]).as_builder()
|
||||
if mismatch:
|
||||
model_msg.modelV2.frameId = target
|
||||
model_msg.modelV2.frameAge = max(model_msg.modelV2.frameAge, source_lag)
|
||||
model_msg.modelV2.frameDropPerc = frame_drop_perc
|
||||
pm.send("modelV2", model_msg)
|
||||
|
||||
driving_msg = log_from_bytes(msgs["drivingModelData"]).as_builder()
|
||||
if mismatch:
|
||||
driving_msg.drivingModelData.frameId = target
|
||||
driving_msg.drivingModelData.frameDropPerc = frame_drop_perc
|
||||
pm.send("drivingModelData", driving_msg)
|
||||
|
||||
pose_msg = log_from_bytes(msgs["cameraOdometry"]).as_builder()
|
||||
if mismatch:
|
||||
pose_msg.cameraOdometry.frameId = target
|
||||
pose_msg.valid = bool(payload["live_calib_seen"]) and selector_dropped < 1 and not mismatch
|
||||
pm.send("cameraOdometry", pose_msg)
|
||||
|
||||
pm.send("iqDriveModelData", msgs["iqDriveModelData"])
|
||||
|
||||
|
||||
def _read_float(params, key: str, default: float) -> float:
|
||||
v = params.get(key)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cloudlog.warning("modeld_selector init")
|
||||
cloudlog.bind(daemon=PROCESS_NAME)
|
||||
setproctitle(PROCESS_NAME)
|
||||
config_realtime_process([0, 1, 2, 3], 54)
|
||||
|
||||
params = Params()
|
||||
keys = resolve_status_keys(params)
|
||||
pwriter = AsyncParamWriter(params)
|
||||
pwriter.put_bool(keys["active"], False)
|
||||
pwriter.put_bool(keys["failed"], False)
|
||||
pm = PubMaster(SELECTOR_SERVICES)
|
||||
|
||||
small_channel: ModelChannel | None = None
|
||||
big_channel: ModelChannel | None = None
|
||||
latch = BigLatch()
|
||||
big_used_count = 0
|
||||
run_count = 0
|
||||
last_published = -1
|
||||
last_big_published = -1
|
||||
frame_dropped_filter = FirstOrderFilter(0.0, 10.0, 1.0 / MODEL_FREQ)
|
||||
recent_big = deque(maxlen=STATUS_WINDOW)
|
||||
model_name = resolve_model_name(params, keys)
|
||||
last_backend_check = 0.0
|
||||
last_latency_ms = 0.0
|
||||
last_source_lag = 0
|
||||
miss_reasons = {"no_head": 0, "already_used": 0, "far_future": 0,
|
||||
"too_stale": 0, "head_prev_timeout": 0, "read_race": 0}
|
||||
|
||||
cloudlog.warning(f"modeld_selector starting (max_big_lag_frames={BIG_MAX_LAG_FRAMES})")
|
||||
while True:
|
||||
if small_channel is None:
|
||||
try:
|
||||
small_channel = ModelChannel(SMALL_CHANNEL, create=False)
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if big_channel is None:
|
||||
try:
|
||||
big_channel = ModelChannel(BIG_CHANNEL, create=False)
|
||||
except OSError:
|
||||
big_channel = None
|
||||
|
||||
fid = small_channel.peek_frame_id()
|
||||
if fid is None or fid == last_published:
|
||||
time.sleep(0.0005)
|
||||
continue
|
||||
if last_published >= 0 and fid < last_published - 1:
|
||||
cloudlog.warning(f"modeld_selector frame reset {last_published} -> {fid}; re-arming")
|
||||
last_published = -1
|
||||
last_big_published = -1
|
||||
big_used_count = 0
|
||||
run_count = 0
|
||||
latch = BigLatch()
|
||||
pwriter.put_bool(keys["active"], False)
|
||||
pwriter.put_bool(keys["failed"], False)
|
||||
|
||||
now_mono = time.monotonic()
|
||||
if now_mono - last_backend_check > 1.0:
|
||||
last_backend_check = now_mono
|
||||
new_keys = resolve_status_keys(params)
|
||||
if new_keys is not keys:
|
||||
cloudlog.warning(f"modeld_selector backend changed {keys['active']} -> {new_keys['active']}; re-arming")
|
||||
pwriter.put_bool(keys["active"], False)
|
||||
pwriter.put_bool(keys["failed"], False)
|
||||
keys = new_keys
|
||||
model_name = resolve_model_name(params, keys)
|
||||
recent_big.clear()
|
||||
last_big_published = -1
|
||||
big_used_count = 0
|
||||
run_count = 0
|
||||
latch = BigLatch()
|
||||
pwriter.put_bool(keys["active"], False)
|
||||
pwriter.put_bool(keys["failed"], False)
|
||||
target = fid
|
||||
t_start = time.perf_counter()
|
||||
|
||||
small_payload = None
|
||||
got = small_channel.read()
|
||||
if got is not None and got[0] == target:
|
||||
small_payload = got[1]
|
||||
|
||||
payload = None
|
||||
used_big = False
|
||||
big_peek = None
|
||||
if big_channel is not None and not latch.done:
|
||||
deadline = t_start + BIG_MODEL_DEADLINE
|
||||
sof_ns = (small_payload or {}).get("timestamp_sof")
|
||||
if sof_ns:
|
||||
remaining = (BIG_ANCHOR_MS / 1000.0) - (time.clock_gettime(time.CLOCK_BOOTTIME) - sof_ns / 1e9)
|
||||
deadline = t_start + min(max(remaining, BIG_WAIT_FLOOR_S), BIG_WAIT_CEIL_S)
|
||||
payload, big_peek = wait_for_big(big_channel, target, deadline,
|
||||
last_big_published, BIG_MAX_LAG_FRAMES)
|
||||
used_big = payload is not None
|
||||
if not used_big:
|
||||
if big_peek is None:
|
||||
miss_reasons["no_head"] += 1
|
||||
elif big_peek <= last_big_published:
|
||||
miss_reasons["already_used"] += 1
|
||||
elif big_peek > target + BIG_FUTURE_ACCEPT:
|
||||
miss_reasons["far_future"] += 1
|
||||
elif target - big_peek > BIG_MAX_LAG_FRAMES:
|
||||
miss_reasons["too_stale"] += 1
|
||||
elif big_peek == target - 1:
|
||||
miss_reasons["head_prev_timeout"] += 1
|
||||
else:
|
||||
miss_reasons["read_race"] += 1
|
||||
|
||||
if payload is None:
|
||||
payload = small_payload
|
||||
if payload is None:
|
||||
got = small_channel.read()
|
||||
if got is not None and got[0] == target:
|
||||
payload = got[1]
|
||||
|
||||
activated_now, failed_now = latch.update(used_big)
|
||||
if activated_now:
|
||||
pwriter.put_bool(keys["active"], True)
|
||||
pwriter.put_bool(keys["failed"], False)
|
||||
cloudlog.warning(f"modeld_selector switched to BIG model at frame {target}")
|
||||
elif failed_now:
|
||||
pwriter.put_bool(keys["active"], False)
|
||||
pwriter.put_bool(keys["failed"], latch.done)
|
||||
pwriter.put(keys["last_error"], "big model stalled onroad; local fallback latched"
|
||||
if latch.done else "big model stalled onroad; small active, big may re-arm")
|
||||
if latch.done:
|
||||
cloudlog.warning(f"modeld_selector big stalled, staying on small until next ignition (frame {target})")
|
||||
else:
|
||||
cloudlog.warning(f"modeld_selector big stalled, small active; big may re-arm after a clean streak (frame {target})")
|
||||
|
||||
if payload is not None:
|
||||
selector_dropped = max(0, target - last_published - 1) if last_published >= 0 else 0
|
||||
frames_dropped = frame_dropped_filter.update(min(selector_dropped, 10))
|
||||
if run_count < WARMUP_FRAMES:
|
||||
frame_dropped_filter.x = 0.0
|
||||
frames_dropped = 0.0
|
||||
run_count += 1
|
||||
recent_big.append(used_big)
|
||||
if used_big:
|
||||
big_used_count += 1
|
||||
big_fid = int(payload.get("frame_id", big_peek if big_peek is not None else target))
|
||||
last_big_published = min(big_fid, target)
|
||||
last_latency_ms = float(payload.get("model_execution_time", 0.0)) * 1e3
|
||||
source_lag = max(0, target - int(payload.get("frame_id", target)))
|
||||
frame_mismatch = int(payload.get("frame_id", target)) != target
|
||||
last_source_lag = source_lag
|
||||
if run_count % STATUS_WINDOW == 0:
|
||||
hit_rate = (sum(recent_big) / len(recent_big)) if recent_big else 0.0
|
||||
pwriter.put(keys["latency_ms"], last_latency_ms)
|
||||
pwriter.put(keys["status"], json.dumps({
|
||||
"active": latch.active,
|
||||
"failed": latch.done,
|
||||
"hit_rate": round(hit_rate, 3),
|
||||
"latency_ms": round(last_latency_ms, 1),
|
||||
"source_lag_frames": last_source_lag,
|
||||
"model": model_name,
|
||||
"reachable": params.get_bool(keys["reachable"]),
|
||||
"download_progress": _read_float(params, keys["progress"], 1.0),
|
||||
"ts_mono": round(time.monotonic(), 1),
|
||||
}))
|
||||
if run_count % 100 == 0:
|
||||
cloudlog.warning(f"modeld_selector misses: {miss_reasons}")
|
||||
cloudlog.warning(f"modeld_selector: big_used={big_used_count}/{run_count} "
|
||||
f"last_big_peek={big_peek} target={target} active={latch.active} "
|
||||
f"max_big_lag={BIG_MAX_LAG_FRAMES}")
|
||||
|
||||
frame_drop_perc = 100.0 * frames_dropped / (1.0 + frames_dropped)
|
||||
_patch_and_send(pm, payload, frame_drop_perc, selector_dropped, target, source_lag, frame_mismatch)
|
||||
last_published = target
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
cloudlog.warning("modeld_selector got SIGINT")
|
||||
@@ -129,7 +129,7 @@ class ModelRunner(RunnerRoot):
|
||||
if not active:
|
||||
raise ValueError("runner started without an active model bundle")
|
||||
|
||||
self.models = {spec.type.raw: ArtifactSpec(spec) for spec in active.models}
|
||||
self.models = {spec.type.raw: ArtifactSpec(spec) for spec in _qcom_models(active)}
|
||||
self.is_20hz_3d = False
|
||||
self.is_20hz = active.is20hz
|
||||
self.inputs = {}
|
||||
@@ -180,8 +180,15 @@ class ModelRunner(RunnerRoot):
|
||||
|
||||
# ---- runner selection (which backend to build for the active bundle) ----------
|
||||
|
||||
def _qcom_models(bundle) -> list:
|
||||
# usbeMac artifacts ride along in a bundle for the eGPU host; they are never
|
||||
# loaded on QCOM and must not affect runner classification
|
||||
return [m for m in bundle.models if m.type.raw != ModelType.usbeMac]
|
||||
|
||||
|
||||
def _single_artifact_prefix(bundle, prefix: str) -> bool:
|
||||
return len(bundle.models) == 1 and bundle.models[0].artifact.fileName.startswith(prefix)
|
||||
models = _qcom_models(bundle)
|
||||
return len(models) == 1 and models[0].artifact.fileName.startswith(prefix)
|
||||
|
||||
|
||||
def _is_fused_bundle(bundle) -> bool:
|
||||
@@ -193,7 +200,7 @@ def _is_supercombo_bundle(bundle) -> bool:
|
||||
|
||||
|
||||
def _is_split_bundle(bundle) -> bool:
|
||||
present = {m.type.raw for m in bundle.models}
|
||||
present = {m.type.raw for m in _qcom_models(bundle)}
|
||||
split_kinds = {ModelType.vision, ModelType.policy, ModelType.offPolicy, ModelType.onPolicy}
|
||||
return not present.isdisjoint(split_kinds)
|
||||
|
||||
@@ -205,7 +212,9 @@ def get_model_runner() -> "ModelRunner":
|
||||
from iqpilot.selfdrive.iqmodeld.models.runners.tinygrad.tinygrad_runner import (TinygradRunner,
|
||||
TinygradSplitRunner)
|
||||
bundle = _fetch_bundle()
|
||||
if not (bundle and bundle.models):
|
||||
# an eMac-only bundle (no QCOM-loadable models) runs the stock default on
|
||||
# device; the big host serves the bundle's precompiled artifact
|
||||
if not (bundle and bundle.models and _qcom_models(bundle)):
|
||||
return TinygradRunner(ModelType.supercombo)
|
||||
|
||||
if _is_supercombo_bundle(bundle):
|
||||
@@ -219,4 +228,4 @@ def get_model_runner() -> "ModelRunner":
|
||||
return TinygradCombinedSplitRunner()
|
||||
if _is_split_bundle(bundle):
|
||||
return TinygradSplitRunner()
|
||||
return TinygradRunner(bundle.models[0].type.raw)
|
||||
return TinygradRunner(_qcom_models(bundle)[0].type.raw)
|
||||
|
||||
131
iqpilot/selfdrive/iqmodeld/temporal_state.py
Normal file
131
iqpilot/selfdrive/iqmodeld/temporal_state.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
DEFAULT_FRAME_SKIP = 4
|
||||
|
||||
MODEL_INPUT_SPEC: dict[str, tuple[tuple[int, ...], str]] = {
|
||||
"img": ((1, 12, 128, 256), "uint8"),
|
||||
"big_img": ((1, 12, 128, 256), "uint8"),
|
||||
"desire_pulse": ((1, 25, 8), "float32"),
|
||||
"traffic_convention": ((1, 2), "float32"),
|
||||
"features_buffer": ((1, 24, 512), "float32"),
|
||||
"action_t": ((1, 2), "float32"),
|
||||
}
|
||||
|
||||
|
||||
def spec_from_meta(meta: dict) -> dict[str, tuple[tuple[int, ...], str]] | None:
|
||||
shapes = meta.get("input_shapes")
|
||||
if not shapes:
|
||||
return None
|
||||
return {name: (tuple(shape), "uint8" if name in ("img", "big_img") else "float32")
|
||||
for name, shape in shapes.items()}
|
||||
|
||||
|
||||
class TemporalInputState:
|
||||
def __init__(self, frame_skip: int, spec: dict[str, tuple[tuple[int, ...], str]] = MODEL_INPUT_SPEC):
|
||||
self.frame_skip = frame_skip
|
||||
img = spec["img"][0]
|
||||
fb = spec["features_buffer"][0]
|
||||
dp = spec["desire_pulse"][0]
|
||||
|
||||
self.n_frames = img[1] // 6
|
||||
img_q_shape = (frame_skip * (self.n_frames - 1) + 1, 6, img[2], img[3])
|
||||
self._img_shape = img
|
||||
self._fb_shape = fb
|
||||
self._dp_shape = dp
|
||||
feat_dim = math.prod(fb[2:])
|
||||
|
||||
self.img_q = np.zeros(img_q_shape, dtype=np.uint8)
|
||||
self.big_img_q = np.zeros(img_q_shape, dtype=np.uint8)
|
||||
self.feat_q = np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32)
|
||||
self.desire_q = np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32)
|
||||
self.prev_desire = np.zeros(dp[2], dtype=np.float32)
|
||||
self.prev_feat = np.zeros((fb[0], feat_dim), dtype=np.float32)
|
||||
|
||||
@staticmethod
|
||||
def _shift_append(q: np.ndarray, new_val: np.ndarray) -> None:
|
||||
q[:-1] = q[1:]
|
||||
q[-1] = new_val
|
||||
|
||||
def push_and_materialize(self, warped: np.ndarray, desire_pulse: np.ndarray,
|
||||
traffic_convention: np.ndarray, action_t: np.ndarray,
|
||||
) -> dict[str, np.ndarray]:
|
||||
fs = self.frame_skip
|
||||
|
||||
cur = desire_pulse.astype(np.float32).copy()
|
||||
cur[0] = 0
|
||||
pulse = np.where(cur - self.prev_desire > 0.99, cur, 0).astype(np.float32)
|
||||
self.prev_desire[:] = cur
|
||||
|
||||
self._shift_append(self.img_q, warped[0])
|
||||
self._shift_append(self.big_img_q, warped[1])
|
||||
self._shift_append(self.desire_q, pulse.reshape(self._dp_shape[0], self._dp_shape[2]))
|
||||
self._shift_append(self.feat_q, self.prev_feat)
|
||||
|
||||
dp = self._dp_shape
|
||||
return {
|
||||
"img": np.ascontiguousarray(self.img_q[::fs]).reshape(self._img_shape),
|
||||
"big_img": np.ascontiguousarray(self.big_img_q[::fs]).reshape(self._img_shape),
|
||||
"features_buffer": np.ascontiguousarray(self.feat_q[::fs]).reshape(self._fb_shape),
|
||||
"desire_pulse": self.desire_q.reshape(dp[1], fs, dp[0], dp[2]).max(axis=1).reshape(dp),
|
||||
"traffic_convention": traffic_convention.astype(np.float32).reshape(1, -1),
|
||||
"action_t": action_t.astype(np.float32).reshape(1, -1),
|
||||
}
|
||||
|
||||
def note_hidden_state(self, model_output: np.ndarray, hidden_slice: slice) -> None:
|
||||
self.prev_feat[:] = model_output[hidden_slice].reshape(self.prev_feat.shape)
|
||||
|
||||
|
||||
class SplitTemporalState:
|
||||
|
||||
def __init__(self, frame_skip: int, img_shape: tuple[int, ...],
|
||||
feature_shape: tuple[int, ...], desire_shape: tuple[int, ...]):
|
||||
self.frame_skip = frame_skip
|
||||
self._img_shape = tuple(img_shape)
|
||||
self._fb_shape = tuple(feature_shape)
|
||||
self._dp_shape = tuple(desire_shape)
|
||||
|
||||
n_frames = img_shape[1] // 6
|
||||
img_q_shape = (frame_skip * (n_frames - 1) + 1, 6, img_shape[2], img_shape[3])
|
||||
self.img_q = np.zeros(img_q_shape, dtype=np.uint8)
|
||||
self.big_img_q = np.zeros(img_q_shape, dtype=np.uint8)
|
||||
self.feat_q = np.zeros((frame_skip * (feature_shape[1] - 1) + 1, feature_shape[0], feature_shape[2]),
|
||||
dtype=np.float32)
|
||||
self.desire_q = np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]), dtype=np.float32)
|
||||
self.prev_desire = np.zeros(desire_shape[2], dtype=np.float32)
|
||||
|
||||
def materialize_vision(self, warped: np.ndarray, desire: np.ndarray) -> dict[str, np.ndarray]:
|
||||
fs = self.frame_skip
|
||||
cur = desire.astype(np.float32).copy()
|
||||
cur[0] = 0
|
||||
pulse = np.where(cur - self.prev_desire > 0.99, cur, 0).astype(np.float32)
|
||||
self.prev_desire[:] = cur
|
||||
|
||||
TemporalInputState._shift_append(self.img_q, warped[0])
|
||||
TemporalInputState._shift_append(self.big_img_q, warped[1])
|
||||
TemporalInputState._shift_append(self.desire_q, pulse.reshape(self._dp_shape[0], self._dp_shape[2]))
|
||||
return {
|
||||
"img": np.ascontiguousarray(self.img_q[::fs]).reshape(self._img_shape),
|
||||
"big_img": np.ascontiguousarray(self.big_img_q[::fs]).reshape(self._img_shape),
|
||||
}
|
||||
|
||||
def materialize_policy(self, vision_feature: np.ndarray, traffic_convention: np.ndarray,
|
||||
action_t: np.ndarray | None = None) -> dict[str, np.ndarray]:
|
||||
fs = self.frame_skip
|
||||
TemporalInputState._shift_append(self.feat_q, vision_feature.reshape(self._fb_shape[0], self._fb_shape[2]))
|
||||
dp = self._dp_shape
|
||||
out = {
|
||||
"features_buffer": np.ascontiguousarray(self.feat_q[::fs]).reshape(self._fb_shape),
|
||||
"desire_pulse": self.desire_q.reshape(dp[1], fs, dp[0], dp[2]).max(axis=1).reshape(dp),
|
||||
"traffic_convention": traffic_convention.astype(np.float32).reshape(1, -1),
|
||||
}
|
||||
if action_t is not None:
|
||||
out["action_t"] = action_t.astype(np.float32).reshape(1, -1)
|
||||
return out
|
||||
163
iqpilot/selfdrive/iqmodeld/tests/test_egpu_stock_parity.py
Normal file
163
iqpilot/selfdrive/iqmodeld/tests/test_egpu_stock_parity.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.cereal import log, messaging
|
||||
from iqpilot.cereal.services import SERVICE_LIST
|
||||
|
||||
|
||||
class TestTelemetryContract:
|
||||
def test_service_is_published_at_stock_cadence(self):
|
||||
assert "egpuDockState" in SERVICE_LIST
|
||||
assert SERVICE_LIST["egpuDockState"].frequency == 10.
|
||||
|
||||
def test_message_carries_every_stock_field(self):
|
||||
msg = messaging.new_message("egpuDockState")
|
||||
state = msg.egpuDockState
|
||||
for field in ("tempC", "memoryTempC", "powerDrawW", "powerLimitW", "gpuUsagePercent",
|
||||
"gpuClockMhz", "fanSpeedRpm", "pcieLtssm", "supplyVoltage", "supplyCurrent"):
|
||||
setattr(state, field, 1)
|
||||
assert getattr(state, field) == 1
|
||||
|
||||
def test_metrics_refresh_matches_stock(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_telemetry import METRICS_REFRESH_EVERY
|
||||
assert METRICS_REFRESH_EVERY == 100
|
||||
|
||||
def test_send_without_a_gpu_publishes_an_invalid_message(self):
|
||||
from iqpilot.selfdrive.iqmodeld import egpu_telemetry
|
||||
sent = []
|
||||
telemetry = egpu_telemetry.EgpuDockTelemetry(types.SimpleNamespace(send=lambda n, m: sent.append((n, m))), big=True)
|
||||
telemetry._device = lambda: types.SimpleNamespace(_opened_devices=set())
|
||||
telemetry.send()
|
||||
assert sent and sent[0][0] == "egpuDockState"
|
||||
assert sent[0][1].valid is False
|
||||
|
||||
|
||||
class TestBigFrameFlag:
|
||||
def test_model_message_carries_the_big_flag(self):
|
||||
msg = messaging.new_message("modelV2")
|
||||
msg.modelV2.big = True
|
||||
assert msg.modelV2.big
|
||||
|
||||
|
||||
class TestStatusParams:
|
||||
def test_loading_param_exists_and_is_cleared_like_stock(self):
|
||||
from pathlib import Path
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
keys = (root / "common" / "params_keys.h").read_text()
|
||||
assert '{"UsbGpuLoading"' in keys
|
||||
line = next(ln for ln in keys.splitlines() if '"UsbGpuLoading"' in ln)
|
||||
for flag in ("CLEAR_ON_MANAGER_START", "CLEAR_ON_OFFROAD_TRANSITION", "CLEAR_ON_IGNITION_ON"):
|
||||
assert flag in line
|
||||
|
||||
|
||||
class TestAlerts:
|
||||
def test_both_stock_big_model_events_exist(self):
|
||||
assert hasattr(log.OnroadEvent.EventName, "bigModelLoading")
|
||||
assert hasattr(log.OnroadEvent.EventName, "bigModelFailed")
|
||||
|
||||
def test_alerts_are_wired_with_stock_severities(self):
|
||||
from iqpilot.selfdrive.selfdrived.events import EVENTS, ET
|
||||
EventName = log.OnroadEvent.EventName
|
||||
loading = EVENTS[EventName.bigModelLoading]
|
||||
failed = EVENTS[EventName.bigModelFailed]
|
||||
assert ET.NO_ENTRY in loading
|
||||
assert ET.SOFT_DISABLE in failed and ET.PERMANENT in failed
|
||||
|
||||
|
||||
class TestFirmwareGate:
|
||||
def test_runtime_refuses_a_dock_on_other_firmware(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import usbgpu_present
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT, EGPU_DOCK_USB_IDS
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
d = tmp_path / "1-1"
|
||||
d.mkdir()
|
||||
(d / "idVendor").write_text(f"{vid:04x}\n")
|
||||
(d / "idProduct").write_text(f"{pid:04x}\n")
|
||||
(d / "product").write_text("custom deadbeef-CLEAN\n")
|
||||
assert not usbgpu_present(str(tmp_path))
|
||||
(d / "product").write_text(EGPU_DOCK_FW_PRODUCT + "\n")
|
||||
assert usbgpu_present(str(tmp_path))
|
||||
|
||||
|
||||
class TestAutoFlash:
|
||||
def test_hardwared_drives_the_flasher_offroad_only(self):
|
||||
from iqpilot.system.hardware.hardwared import EgpuDockFlasher
|
||||
f = EgpuDockFlasher()
|
||||
calls = []
|
||||
f.flash = lambda: calls.append(1)
|
||||
stale = [{"vendorId": 0xADD1, "productId": 0x0001, "product": "custom deadbeef-CLEAN"}]
|
||||
f.update(False, stale)
|
||||
assert f.attempts == 0, "must not flash onroad"
|
||||
f.update(True, stale)
|
||||
assert f.attempts == 1
|
||||
if f.thread is not None:
|
||||
f.thread.join(timeout=5)
|
||||
|
||||
def test_matching_firmware_is_never_flashed(self):
|
||||
from iqpilot.system.hardware.egpu_dock.flash import bundled_version
|
||||
from iqpilot.system.hardware.hardwared import EgpuDockFlasher
|
||||
f = EgpuDockFlasher()
|
||||
f.flash = lambda: pytest.fail("flashed a dock that already matches")
|
||||
f.update(True, [{"vendorId": 0xADD1, "productId": 0x0001, "product": bundled_version()}])
|
||||
assert f.attempts == 0
|
||||
|
||||
def test_attempts_are_bounded_like_stock(self):
|
||||
from iqpilot.system.hardware.hardwared import EgpuDockFlasher
|
||||
assert EgpuDockFlasher.MAX_ATTEMPTS == 3
|
||||
assert EgpuDockFlasher.RETRY_INTERVAL == 20.
|
||||
|
||||
|
||||
class TestDockIsItsOwnConsent:
|
||||
|
||||
def _params(self, **flags):
|
||||
class P:
|
||||
def get_bool(self, k):
|
||||
return bool(flags.get(k, False))
|
||||
def get(self, k, *a, **kw):
|
||||
return None
|
||||
return P()
|
||||
|
||||
def _sysfs_with_dock(self, tmp_path, product=None):
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT, EGPU_DOCK_USB_IDS
|
||||
vid, pid = EGPU_DOCK_USB_IDS[0]
|
||||
d = tmp_path / "1-1"
|
||||
d.mkdir()
|
||||
(d / "idVendor").write_text(f"{vid:04x}\n")
|
||||
(d / "idProduct").write_text(f"{pid:04x}\n")
|
||||
(d / "product").write_text((product or EGPU_DOCK_FW_PRODUCT) + "\n")
|
||||
return str(tmp_path)
|
||||
|
||||
def test_a_plugged_in_dock_selects_itself(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
assert egpu_selected(self._params(), self._sysfs_with_dock(tmp_path))
|
||||
|
||||
def test_nothing_plugged_in_selects_nothing(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
assert not egpu_selected(self._params(), str(tmp_path))
|
||||
|
||||
def test_a_dock_on_foreign_firmware_does_not_select_itself(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
assert not egpu_selected(self._params(), self._sysfs_with_dock(tmp_path, "custom deadbeef-CLEAN"))
|
||||
|
||||
def test_the_user_can_force_it_off(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
root = self._sysfs_with_dock(tmp_path)
|
||||
assert not egpu_selected(self._params(IQEgpuDisabled=True), root)
|
||||
|
||||
def test_the_param_can_force_it_on_without_hardware(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
assert egpu_selected(self._params(IQEgpuEnabled=True), str(tmp_path))
|
||||
|
||||
def test_present_dock_wins_even_with_emac_enabled(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected, resolve_backend, usbgpu_present
|
||||
root = self._sysfs_with_dock(tmp_path)
|
||||
assert resolve_backend(True, egpu_selected(self._params(), root), usbgpu_present(root)) == "egpu"
|
||||
|
||||
def test_force_param_without_hardware_yields_to_emac(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected, resolve_backend, usbgpu_present
|
||||
assert resolve_backend(True, egpu_selected(self._params(IQEgpuEnabled=True), str(tmp_path)),
|
||||
usbgpu_present(str(tmp_path))) == "emac"
|
||||
509
iqpilot/selfdrive/iqmodeld/tests/test_egpu_worker.py
Normal file
509
iqpilot/selfdrive/iqmodeld/tests/test_egpu_worker.py
Normal file
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import (
|
||||
resolve_backend, resolve_download_url, usbgpu_present,
|
||||
)
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_pipeline import (
|
||||
EgpuPipeline, EgpuPipelineError, make_big_channel_payload,
|
||||
)
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import EGPU_MODELS, get_egpu_model
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import MODEL_INPUT_SPEC as INPUT_SPEC
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def __init__(self, **flags):
|
||||
self._flags = {k: bool(v) for k, v in flags.items()}
|
||||
|
||||
def get_bool(self, key: str) -> bool:
|
||||
return self._flags.get(key, False)
|
||||
|
||||
|
||||
def _fake_usb_device(root, vid: str, pid: str, name: str = "1-1", product: str | None = None):
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT
|
||||
d = root / name
|
||||
d.mkdir()
|
||||
(d / "idVendor").write_text(vid + "\n")
|
||||
(d / "idProduct").write_text(pid + "\n")
|
||||
(d / "product").write_text((product if product is not None else EGPU_DOCK_FW_PRODUCT) + "\n")
|
||||
|
||||
|
||||
class TestPresence:
|
||||
def test_present(self, tmp_path):
|
||||
_fake_usb_device(tmp_path, "add1", "0001")
|
||||
assert usbgpu_present(str(tmp_path))
|
||||
|
||||
def test_foreign_firmware_absent(self, tmp_path):
|
||||
_fake_usb_device(tmp_path, "add1", "0001", product="custom deadbeef-CLEAN")
|
||||
assert not usbgpu_present(str(tmp_path))
|
||||
|
||||
def test_wrong_ids_absent(self, tmp_path):
|
||||
_fake_usb_device(tmp_path, "05ac", "12a8")
|
||||
assert not usbgpu_present(str(tmp_path))
|
||||
|
||||
def test_empty_bus_absent(self, tmp_path):
|
||||
assert not usbgpu_present(str(tmp_path))
|
||||
|
||||
def test_unreadable_entries_skipped(self, tmp_path):
|
||||
(tmp_path / "usb1").mkdir()
|
||||
_fake_usb_device(tmp_path, "add1", "0001", name="1-2")
|
||||
assert usbgpu_present(str(tmp_path))
|
||||
|
||||
|
||||
class TestBackendResolution:
|
||||
def test_none(self):
|
||||
assert resolve_backend(False, False) is None
|
||||
|
||||
def test_emac_only(self):
|
||||
assert resolve_backend(True, False) == "emac"
|
||||
|
||||
def test_egpu_only(self):
|
||||
assert resolve_backend(False, True) == "egpu"
|
||||
|
||||
def test_force_param_yields_to_emac_without_hardware(self):
|
||||
assert resolve_backend(True, True) == "emac"
|
||||
|
||||
def test_present_dock_wins_over_emac(self):
|
||||
assert resolve_backend(True, True, True) == "egpu"
|
||||
|
||||
|
||||
class TestManagerGating:
|
||||
@pytest.fixture
|
||||
def pc(self):
|
||||
return pytest.importorskip("iqpilot.system.manager.process_config")
|
||||
|
||||
def test_egpu_needs_presence(self, pc, monkeypatch):
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: True)
|
||||
assert pc.egpu_enabled(True, FakeParams(IQEgpuEnabled=True), None)
|
||||
assert pc.egpu_enabled(True, FakeParams(), None)
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: False)
|
||||
assert not pc.egpu_enabled(True, FakeParams(IQEgpuEnabled=True), None)
|
||||
assert not pc.egpu_enabled(True, FakeParams(), None)
|
||||
|
||||
def test_present_dock_wins_over_left_on_emac(self, pc, monkeypatch):
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: True)
|
||||
both = FakeParams(IQEmacEnabled=True, IQEgpuEnabled=True)
|
||||
assert not pc.emac_enabled(True, both, None)
|
||||
assert pc.egpu_enabled(True, both, None)
|
||||
|
||||
def test_emac_runs_when_no_dock(self, pc, monkeypatch):
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: False)
|
||||
assert pc.emac_enabled(True, FakeParams(IQEmacEnabled=True), None)
|
||||
|
||||
def test_disabled_dock_yields_to_emac(self, pc, monkeypatch):
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: True)
|
||||
both = FakeParams(IQEmacEnabled=True, IQEgpuDisabled=True)
|
||||
assert pc.emac_enabled(True, both, None)
|
||||
assert not pc.egpu_enabled(True, both, None)
|
||||
|
||||
def test_disabled_dock_runs_no_backend_when_no_emac(self, pc, monkeypatch):
|
||||
monkeypatch.setattr(pc, "usbgpu_present", lambda: True)
|
||||
off = FakeParams(IQEgpuDisabled=True)
|
||||
assert not pc.egpu_enabled(True, off, None)
|
||||
assert not pc.emac_enabled(True, off, None)
|
||||
|
||||
def test_selector_runs_for_either_backend(self, pc):
|
||||
assert pc.big_model_enabled(True, FakeParams(IQEmacEnabled=True), None)
|
||||
assert pc.big_model_enabled(True, FakeParams(IQEgpuEnabled=True), None)
|
||||
assert not pc.big_model_enabled(True, FakeParams(), None)
|
||||
|
||||
def test_iqegpumodeld_registered(self, pc):
|
||||
assert "iqegpumodeld" in pc.managed_processes
|
||||
assert "maciqmodeld" in pc.managed_processes
|
||||
|
||||
|
||||
class TestDownloadResolve:
|
||||
def test_direct_url_passthrough(self):
|
||||
assert resolve_download_url("https://x/y.onnx", "0" * 64, 5) == "https://x/y.onnx"
|
||||
|
||||
def test_commalfs_batch(self, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
seen["url"] = req.full_url
|
||||
seen["body"] = json.loads(req.data)
|
||||
return io.BytesIO(json.dumps(
|
||||
{"objects": [{"actions": {"download": {"href": "https://signed/url"}}}]}).encode())
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
sha = "a5" * 32
|
||||
url = resolve_download_url(f"commalfs:{sha}", sha, 1234)
|
||||
assert url == "https://signed/url"
|
||||
assert seen["body"]["objects"] == [{"oid": sha, "size": 1234}]
|
||||
assert seen["url"].endswith("/info/lfs/objects/batch")
|
||||
|
||||
|
||||
def _zero_infer(output_len: int, fill=None):
|
||||
calls = []
|
||||
|
||||
def infer(inputs):
|
||||
for name, (shape, dtype) in INPUT_SPEC.items():
|
||||
assert tuple(inputs[name].shape) == shape, name
|
||||
assert inputs[name].dtype == np.dtype(dtype), name
|
||||
calls.append({k: v.copy() for k, v in inputs.items()})
|
||||
out = np.zeros(output_len, dtype=np.float32)
|
||||
if fill is not None:
|
||||
out[:] = fill
|
||||
return out
|
||||
|
||||
infer.calls = calls
|
||||
return infer
|
||||
|
||||
|
||||
def _frame_inputs(seed=0):
|
||||
rng = np.random.default_rng(seed)
|
||||
warped = rng.integers(0, 256, (2, 6, 128, 256)).astype(np.uint8)
|
||||
desire = np.zeros(8, dtype=np.float32)
|
||||
traffic = np.array([1.0, 0.0], dtype=np.float32)
|
||||
action_t = np.array([0.25, 0.55], dtype=np.float32)
|
||||
return warped, desire, traffic, action_t
|
||||
|
||||
|
||||
class TestEgpuPipeline:
|
||||
def setup_method(self):
|
||||
self.meta = get_egpu_model()
|
||||
|
||||
def test_split_model_rejected(self):
|
||||
split_meta = {**get_egpu_model(), "key": "some_split", "split": True}
|
||||
with pytest.raises(EgpuPipelineError, match="split"):
|
||||
EgpuPipeline(split_meta, _zero_infer(split_meta["output_len"]))
|
||||
|
||||
def test_registry_is_fused_only(self):
|
||||
assert not any(m.get("split") for m in EGPU_MODELS.values())
|
||||
|
||||
def test_run_shapes_and_output(self):
|
||||
infer = _zero_infer(self.meta["output_len"])
|
||||
pipe = EgpuPipeline(self.meta, infer)
|
||||
out = pipe.run(*_frame_inputs())
|
||||
assert out.shape == (self.meta["output_len"],)
|
||||
assert len(infer.calls) == 1
|
||||
|
||||
def test_hidden_state_feeds_next_features_buffer(self):
|
||||
output_len = self.meta["output_len"]
|
||||
hidden = self.meta["output_slices"]["hidden_state"]
|
||||
|
||||
def infer(inputs):
|
||||
out = np.zeros(output_len, dtype=np.float32)
|
||||
out[hidden] = np.arange(hidden.stop - hidden.start, dtype=np.float32)
|
||||
return out
|
||||
|
||||
pipe = EgpuPipeline(self.meta, infer)
|
||||
pipe.run(*_frame_inputs(1))
|
||||
np.testing.assert_array_equal(
|
||||
pipe.state.prev_feat.reshape(-1), np.arange(hidden.stop - hidden.start, dtype=np.float32))
|
||||
pipe.run(*_frame_inputs(2))
|
||||
np.testing.assert_array_equal(
|
||||
pipe.state.feat_q[-1].reshape(-1), np.arange(hidden.stop - hidden.start, dtype=np.float32))
|
||||
|
||||
def test_desire_rising_edge_pulse(self):
|
||||
infer = _zero_infer(self.meta["output_len"])
|
||||
pipe = EgpuPipeline(self.meta, infer)
|
||||
warped, _, traffic, action_t = _frame_inputs()
|
||||
desire_on = np.zeros(8, dtype=np.float32)
|
||||
desire_on[3] = 1.0
|
||||
pipe.run(warped, desire_on, traffic, action_t)
|
||||
assert infer.calls[-1]["desire_pulse"][0, -1, 3] == 1.0
|
||||
for _ in range(5):
|
||||
pipe.run(warped, desire_on, traffic, action_t)
|
||||
assert infer.calls[-1]["desire_pulse"][0, :, 3].sum() == 1.0
|
||||
|
||||
def test_wrong_output_len_raises(self):
|
||||
pipe = EgpuPipeline(self.meta, _zero_infer(self.meta["output_len"] - 1))
|
||||
with pytest.raises(EgpuPipelineError, match="length"):
|
||||
pipe.run(*_frame_inputs())
|
||||
|
||||
def test_non_finite_output_raises(self):
|
||||
pipe = EgpuPipeline(self.meta, _zero_infer(self.meta["output_len"], fill=np.nan))
|
||||
with pytest.raises(EgpuPipelineError, match="finite"):
|
||||
pipe.run(*_frame_inputs())
|
||||
|
||||
|
||||
class TestChannelContract:
|
||||
def _real_msgs(self):
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
msgs = {}
|
||||
for svc in ("modelV2", "drivingModelData", "cameraOdometry", "iqDriveModelData"):
|
||||
m = messaging.new_message(svc)
|
||||
msgs[svc] = m.to_bytes()
|
||||
return msgs
|
||||
|
||||
def test_payload_keys_match_selector_contract(self):
|
||||
payload = make_big_channel_payload(7, True, 0.031, 24.0, {"modelV2": b"x"})
|
||||
assert payload["source"] == "egpu_big"
|
||||
for key in ("frame_id", "live_calib_seen", "model_execution_time", "msgs"):
|
||||
assert key in payload
|
||||
|
||||
def test_selector_consumes_egpu_payload(self, tmp_path):
|
||||
from iqpilot.selfdrive.iqmodeld.model_channel import ModelChannel
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import wait_for_big
|
||||
|
||||
chan = ModelChannel(str(tmp_path / "big"), create=True)
|
||||
payload = make_big_channel_payload(100, True, 0.03, 25.0, self._real_msgs())
|
||||
chan.write(100, payload)
|
||||
|
||||
got, peek = wait_for_big(chan, 100, time.perf_counter() + 0.01)
|
||||
assert peek == 100
|
||||
assert got is not None
|
||||
assert got["source"] == "egpu_big"
|
||||
assert got["frame_id"] == 100
|
||||
|
||||
def test_selector_patch_and_send_parses_egpu_msgs(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import _patch_and_send
|
||||
|
||||
sent = {}
|
||||
|
||||
class PM:
|
||||
def send(self, service, msg):
|
||||
sent[service] = msg
|
||||
|
||||
payload = make_big_channel_payload(42, True, 0.03, 25.0, self._real_msgs())
|
||||
_patch_and_send(PM(), payload, frame_drop_perc=0.0, selector_dropped=0, target=42, source_lag=0)
|
||||
assert set(sent) == {"modelV2", "drivingModelData", "cameraOdometry", "iqDriveModelData"}
|
||||
assert sent["modelV2"].modelV2.frameDropPerc == 0.0
|
||||
assert sent["cameraOdometry"].valid
|
||||
|
||||
def test_selector_lag_patches_frame_id(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import _patch_and_send
|
||||
|
||||
sent = {}
|
||||
|
||||
class PM:
|
||||
def send(self, service, msg):
|
||||
sent[service] = msg
|
||||
|
||||
payload = make_big_channel_payload(40, True, 0.03, 25.0, self._real_msgs())
|
||||
_patch_and_send(PM(), payload, frame_drop_perc=0.0, selector_dropped=0, target=42, source_lag=2)
|
||||
assert sent["modelV2"].modelV2.frameId == 42
|
||||
assert not sent["cameraOdometry"].valid
|
||||
|
||||
|
||||
def _import_worker():
|
||||
try:
|
||||
import iqpilot.selfdrive.iqmodeld.iqegpumodeld as w
|
||||
return w
|
||||
except ImportError as e:
|
||||
if any(tag in str(e) for tag in ("pyx", "visionipc", "proprietary_runtime")):
|
||||
pytest.skip(f"device-only import chain unavailable on this host: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class TestWorkerModule:
|
||||
def test_module_imports_off_device(self):
|
||||
w = _import_worker()
|
||||
assert w.PROCESS_NAME.endswith("iqegpumodeld")
|
||||
assert callable(w.main)
|
||||
|
||||
def test_warmup_validates_output(self):
|
||||
w = _import_worker()
|
||||
spec = {name: (shape, dtype) for name, (shape, dtype) in INPUT_SPEC.items()}
|
||||
def good(inputs):
|
||||
return np.zeros(10, dtype=np.float32)
|
||||
assert w._warmup(good, spec, 10) >= 0.0
|
||||
with pytest.raises(RuntimeError, match="invalid"):
|
||||
w._warmup(good, spec, 11)
|
||||
|
||||
|
||||
class TestSelectorBackendKeys:
|
||||
def test_emac_default(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import EMAC_STATUS_KEYS, backend_status_keys
|
||||
assert backend_status_keys(False, False) is EMAC_STATUS_KEYS
|
||||
assert backend_status_keys(True, False) is EMAC_STATUS_KEYS
|
||||
|
||||
def test_egpu_selected(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import EGPU_STATUS_KEYS, backend_status_keys
|
||||
assert backend_status_keys(False, True) is EGPU_STATUS_KEYS
|
||||
assert backend_status_keys(False, True)["active"] == "UsbGpuActive"
|
||||
assert backend_status_keys(False, True)["failed"] == "UsbGpuFailed"
|
||||
|
||||
def test_emac_wins_when_both(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import EMAC_STATUS_KEYS, backend_status_keys
|
||||
assert backend_status_keys(True, True) is EMAC_STATUS_KEYS
|
||||
|
||||
def test_key_maps_cover_same_roles(self):
|
||||
from iqpilot.selfdrive.iqmodeld.modeld_selector import EGPU_STATUS_KEYS, EMAC_STATUS_KEYS
|
||||
assert set(EGPU_STATUS_KEYS) == set(EMAC_STATUS_KEYS)
|
||||
|
||||
|
||||
class TestBackendSeparation:
|
||||
EGPU_SOURCES = (
|
||||
"egpu_helpers.py", "egpu_pipeline.py", "egpu_model.py", "iqegpumodeld.py",
|
||||
"big_catalog.py", "tools/compile_egpu_model.py",
|
||||
)
|
||||
BANNED_IMPORTS = ("emac_input_state", "emac_model_meta", "maciqmodeld", "mac_protocol", "mac_client")
|
||||
|
||||
def _sources(self):
|
||||
import pathlib
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
return {name: (root / name).read_text() for name in self.EGPU_SOURCES}
|
||||
|
||||
def test_no_emac_module_imports(self):
|
||||
for name, src in self._sources().items():
|
||||
for banned in self.BANNED_IMPORTS:
|
||||
assert f"import {banned}" not in src and f"iqmodeld.{banned}" not in src, f"{name} imports {banned}"
|
||||
|
||||
def test_no_macmodel_params(self):
|
||||
for name, src in self._sources().items():
|
||||
assert "MacModel" not in src, f"{name} references MacModel* params"
|
||||
|
||||
def test_emac_shim_reexports_temporal_state(self):
|
||||
from iqpilot.selfdrive.iqmodeld import emac_input_state, temporal_state
|
||||
assert emac_input_state.EmacInputState is temporal_state.TemporalInputState
|
||||
assert emac_input_state.SplitInputState is temporal_state.SplitTemporalState
|
||||
|
||||
def test_emac_modules_are_not_in_the_public_tree(self):
|
||||
import pathlib
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
for gone in ("mac_protocol.py", "mac_client.py", "maciqmodeld.py", "bulk_transport.py"):
|
||||
assert not (root / gone).exists(), f"{gone} must live only in konn3kt_private"
|
||||
|
||||
|
||||
class TestMetaDrivenInputSpec:
|
||||
|
||||
def _run_one(self, meta):
|
||||
seen = {}
|
||||
def infer(inputs):
|
||||
seen.update({k: v.shape for k, v in inputs.items()})
|
||||
return np.zeros(meta["output_len"], dtype=np.float32)
|
||||
pipe = EgpuPipeline(meta, infer)
|
||||
pipe.run(np.zeros((2, 6, 128, 256), np.uint8), np.zeros(8, np.float32),
|
||||
np.array([1, 0], np.float32), np.zeros(2, np.float32))
|
||||
return seen
|
||||
|
||||
def test_default_contract_unchanged(self):
|
||||
meta = get_egpu_model()
|
||||
seen = self._run_one(meta)
|
||||
assert seen["features_buffer"] == (1, 24, 512)
|
||||
assert seen["desire_pulse"] == (1, 25, 8)
|
||||
|
||||
def test_registry_shapes_drive_the_state(self):
|
||||
meta = dict(get_egpu_model())
|
||||
meta["output_len"] = 18452
|
||||
meta["output_slices"] = dict(meta["output_slices"], hidden_state=slice(2066, 18450))
|
||||
meta["input_shapes"] = {
|
||||
"img": (1, 12, 128, 256), "big_img": (1, 12, 128, 256),
|
||||
"desire_pulse": (1, 33, 8), "traffic_convention": (1, 2),
|
||||
"action_t": (1, 2), "features_buffer": (1, 32, 32, 512),
|
||||
}
|
||||
seen = self._run_one(meta)
|
||||
assert seen["features_buffer"] == (1, 32, 32, 512)
|
||||
assert seen["desire_pulse"] == (1, 33, 8)
|
||||
|
||||
|
||||
class TestCatalogResolution:
|
||||
|
||||
def _params(self, model, doc=None):
|
||||
class P:
|
||||
def get(self, k):
|
||||
if k == "IQEmacModel":
|
||||
return model
|
||||
if k == "IQEmacCatalogCache":
|
||||
return json.dumps(doc) if doc else None
|
||||
return None
|
||||
return P()
|
||||
|
||||
def _doc(self):
|
||||
return {"schema": 1, "bundles": [{
|
||||
"short_name": "ttx", "display_name": "TTx", "index": 1,
|
||||
"model_name": "big_driving_supercombo",
|
||||
"wire": {"output_len": 2580, "frame_skip": 4, "pipeline": True,
|
||||
"output_slices": {"plan": [917, 1907], "hidden_state": [2066, 2578], "pad": [-2, None]},
|
||||
"input_shapes": {"img": [1, 12, 128, 256], "big_img": [1, 12, 128, 256],
|
||||
"desire_pulse": [1, 33, 8], "traffic_convention": [1, 2],
|
||||
"action_t": [1, 2], "features_buffer": [1, 32, 512]},
|
||||
"lat_smooth_seconds": 0.1},
|
||||
"source": {"kind": "comma_lfs", "sha256": "c" * 64, "size": 1},
|
||||
}]}
|
||||
|
||||
def test_unset_selection_is_the_builtin_default(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model
|
||||
m = resolve_egpu_model(self._params(None))
|
||||
assert m["key"] == "lebrowski" and m["sha256"].startswith("a501760a")
|
||||
|
||||
def test_catalog_selection_resolves_with_shapes_and_smoothing(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model
|
||||
m = resolve_egpu_model(self._params("ttx", self._doc()))
|
||||
assert m["key"] == "ttx"
|
||||
assert m["input_shapes"]["features_buffer"] == (1, 32, 512)
|
||||
assert m["input_shapes"]["desire_pulse"] == (1, 33, 8)
|
||||
assert m["lat_smooth_seconds"] == 0.1
|
||||
assert m["output_slices"]["pad"] == slice(-2, None)
|
||||
|
||||
def test_unknown_selection_is_a_park_not_a_silent_default(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model
|
||||
assert resolve_egpu_model(self._params("ghost", self._doc()), allow_refresh=False) is None
|
||||
|
||||
def test_bench_model_is_not_selectable(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model
|
||||
assert resolve_egpu_model(self._params("comma_small", self._doc()), allow_refresh=False) is None
|
||||
|
||||
def test_registry_carries_no_model_list(self):
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import EGPU_MODELS
|
||||
assert set(EGPU_MODELS) == {"lebrowski", "comma_small"}
|
||||
|
||||
|
||||
class TestConsentAndIntegrity:
|
||||
def test_disabled_param_denies_present_dock(self, monkeypatch):
|
||||
from iqpilot.selfdrive.iqmodeld import egpu_helpers
|
||||
monkeypatch.setattr(egpu_helpers, "usbgpu_present", lambda sysfs_root=egpu_helpers.USB_SYSFS_ROOT: True)
|
||||
assert egpu_helpers.egpu_present_consented(FakeParams()) is True
|
||||
assert egpu_helpers.egpu_present_consented(FakeParams(IQEgpuDisabled=True)) is False
|
||||
|
||||
def test_local_onnx_quarantines_bad_content(self, tmp_path, monkeypatch):
|
||||
import hashlib
|
||||
from iqpilot.selfdrive.iqmodeld import egpu_helpers
|
||||
onnx = tmp_path / "m.onnx"
|
||||
onnx.write_bytes(b"good")
|
||||
meta = {"sha256": hashlib.sha256(b"good").hexdigest(), "download": {"size": 4}}
|
||||
monkeypatch.setattr(egpu_helpers, "onnx_cache_path", lambda m: str(onnx))
|
||||
assert egpu_helpers.local_onnx(meta) == str(onnx)
|
||||
onnx.write_bytes(b"bad!")
|
||||
assert egpu_helpers.local_onnx(meta) is None
|
||||
assert not onnx.exists()
|
||||
assert (tmp_path / "m.onnx.unusable").exists()
|
||||
|
||||
|
||||
class TestEgpuDockStatus:
|
||||
def _run(self, seq):
|
||||
from iqpilot.system.hardware.egpu_dock.status import EgpuDockStatus
|
||||
st = EgpuDockStatus()
|
||||
fired = {}
|
||||
def set_alert(name, cond, extra=None):
|
||||
fired[name] = (bool(cond), extra)
|
||||
for args in seq:
|
||||
st.update(*args, set_alert)
|
||||
return {k: v for k, v in fired.items() if v[0]}
|
||||
|
||||
def _dock(self, speed=10000, product="custom ed4e39b7-CLEAN"):
|
||||
return [{"vendorId": 0xADD1, "productId": 0x0001, "product": product, "speedMbps": speed}]
|
||||
|
||||
def test_no_dock_no_alerts(self):
|
||||
assert self._run([(True, [], False, False, None, True, None)]) == {}
|
||||
|
||||
def test_usb2_dock_warns_slow(self):
|
||||
fired = self._run([(True, self._dock(speed=480), False, False, None, True, None)])
|
||||
assert fired.get("Offroad_EgpuUsbSlow") == (True, "480 Mbps")
|
||||
|
||||
def test_power_fault_reports_pcie_unavailable(self):
|
||||
class St:
|
||||
supplyFault = True
|
||||
supplyVoltage = 0
|
||||
pcieLtssm = 0x78
|
||||
tempC = memoryTempC = 40.0
|
||||
fanSpeedRpm = 1500
|
||||
d = self._dock()
|
||||
fired = self._run([
|
||||
(True, d, False, False, None, True, None),
|
||||
(False, d, False, True, None, True, None),
|
||||
(False, d, False, False, b"1", True, St()),
|
||||
])
|
||||
assert "Offroad_EgpuPcieUnavailable" in fired
|
||||
111
iqpilot/selfdrive/iqmodeld/tests/test_emac_input_state.py
Normal file
111
iqpilot/selfdrive/iqmodeld/tests/test_emac_input_state.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DEV", "CPU")
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.emac_input_state import EmacInputState
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import FRAME_SKIP, OUTPUT_LEN, OUTPUT_SLICES
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import MODEL_INPUT_SPEC as INPUT_SPEC
|
||||
|
||||
N_FRAMES_TEST = 30
|
||||
IMG_SHAPE = INPUT_SPEC["img"][0]
|
||||
DESIRE_LEN = INPUT_SPEC["desire_pulse"][0][2]
|
||||
|
||||
|
||||
class _CaptureRunner:
|
||||
|
||||
def __init__(self):
|
||||
self.captured: dict[str, np.ndarray] | None = None
|
||||
|
||||
def __call__(self, inputs):
|
||||
from tinygrad import Tensor
|
||||
self.captured = {k: v.numpy().copy() for k, v in inputs.items()}
|
||||
return {"outputs": Tensor(np.zeros((1, OUTPUT_LEN), dtype=np.float32))}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference():
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_supercombo import (
|
||||
POLICY_INPUTS, make_input_queues, make_run_policy,
|
||||
)
|
||||
|
||||
input_shapes = {name: shape for name, (shape, _) in INPUT_SPEC.items()}
|
||||
metadata = {"input_shapes": input_shapes}
|
||||
capture = _CaptureRunner()
|
||||
run_policy = make_run_policy(capture, metadata, FRAME_SKIP)
|
||||
queues, npy = make_input_queues(input_shapes, FRAME_SKIP, device="CPU")
|
||||
return run_policy, queues, npy, capture, POLICY_INPUTS
|
||||
|
||||
|
||||
def _rising_edge(raw_desire: np.ndarray, prev: np.ndarray) -> np.ndarray:
|
||||
cur = raw_desire.astype(np.float32).copy()
|
||||
cur[0] = 0
|
||||
pulse = np.where(cur - prev > 0.99, cur, 0).astype(np.float32)
|
||||
prev[:] = cur
|
||||
return pulse
|
||||
|
||||
|
||||
def test_materialized_inputs_match_tinygrad_reference(reference):
|
||||
from tinygrad import Tensor
|
||||
|
||||
run_policy, queues, npy, capture, policy_inputs = reference
|
||||
rng = np.random.default_rng(1234)
|
||||
state = EmacInputState(FRAME_SKIP)
|
||||
ref_prev_desire = np.zeros(DESIRE_LEN, dtype=np.float32)
|
||||
|
||||
hidden = np.zeros((1, 512), dtype=np.float32)
|
||||
for frame in range(N_FRAMES_TEST):
|
||||
warped = rng.integers(0, 256, (2, 6, IMG_SHAPE[2], IMG_SHAPE[3]), dtype=np.int64).astype(np.uint8)
|
||||
raw_desire = np.zeros(DESIRE_LEN, dtype=np.float32)
|
||||
if frame % 3:
|
||||
raw_desire[int(rng.integers(0, DESIRE_LEN))] = 1.0
|
||||
traffic = rng.standard_normal(2).astype(np.float32)
|
||||
action_t = rng.standard_normal(2).astype(np.float32)
|
||||
|
||||
npy["desire"][:] = _rising_edge(raw_desire, ref_prev_desire)
|
||||
npy["traffic_convention"][:] = traffic
|
||||
npy["action_t"][:] = action_t
|
||||
npy["prev_feat"][:] = hidden
|
||||
run_policy(warped=Tensor(warped), **{k: queues[k] for k in policy_inputs})
|
||||
ref_inputs = capture.captured
|
||||
|
||||
state.prev_feat[:] = hidden
|
||||
mat = state.push_and_materialize(warped, raw_desire, traffic, action_t)
|
||||
|
||||
for name in INPUT_SPEC:
|
||||
assert ref_inputs[name].shape == tuple(INPUT_SPEC[name][0]), name
|
||||
np.testing.assert_array_equal(
|
||||
mat[name].astype(ref_inputs[name].dtype), ref_inputs[name],
|
||||
err_msg=f"frame {frame}: materialized {name} diverges from tinygrad reference")
|
||||
|
||||
fake_output = rng.standard_normal(OUTPUT_LEN).astype(np.float32)
|
||||
state.note_hidden_state(fake_output, OUTPUT_SLICES["hidden_state"])
|
||||
hidden = fake_output[OUTPUT_SLICES["hidden_state"]].reshape(1, 512).copy()
|
||||
|
||||
|
||||
def test_note_hidden_state_slice():
|
||||
state = EmacInputState(FRAME_SKIP)
|
||||
out = np.arange(OUTPUT_LEN, dtype=np.float32)
|
||||
state.note_hidden_state(out, OUTPUT_SLICES["hidden_state"])
|
||||
np.testing.assert_array_equal(state.prev_feat.reshape(-1), out[OUTPUT_SLICES["hidden_state"]])
|
||||
|
||||
|
||||
def test_desire_pulse_rising_edge_only_once():
|
||||
state = EmacInputState(FRAME_SKIP)
|
||||
held = np.zeros(DESIRE_LEN, dtype=np.float32)
|
||||
held[3] = 1.0
|
||||
warped = np.zeros((2, 6, IMG_SHAPE[2], IMG_SHAPE[3]), dtype=np.uint8)
|
||||
zeros2 = np.zeros(2, dtype=np.float32)
|
||||
|
||||
first = state.push_and_materialize(warped, held, zeros2, zeros2)
|
||||
assert first["desire_pulse"][0, -1, 3] == 1.0
|
||||
second = state.push_and_materialize(warped, held, zeros2, zeros2)
|
||||
assert state.desire_q[-1].max() == 0.0
|
||||
assert second["desire_pulse"][0, -1, 3] == 1.0
|
||||
@@ -34,6 +34,7 @@ def _daemon(params, steer_control_type):
|
||||
return SimpleNamespace(
|
||||
_params=params,
|
||||
_car_params=car_params,
|
||||
_channel=None,
|
||||
_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),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
The eMac bundles ship `minimum_selector_version = 17`, and the version
|
||||
gate lives in the COMPILED private selector bundle, not in this repo. If that
|
||||
bundle is rebuilt from stale source the gate still reads 16, every eMac bundle
|
||||
is silently dropped as "too new", and the selector simply shows no eMac models
|
||||
— with no error anywhere. Assert the effective gate instead, so a stale
|
||||
private bundle fails here rather than on a device.
|
||||
"""
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import EMAC_BUNDLE_MIN_SELECTOR_VERSION
|
||||
from iqpilot.selfdrive.iqmodeld.models.helpers import is_bundle_version_compatible
|
||||
|
||||
|
||||
def test_gate_accepts_the_version_our_emac_bundles_ship():
|
||||
assert is_bundle_version_compatible({"minimumSelectorVersion": EMAC_BUNDLE_MIN_SELECTOR_VERSION}), (
|
||||
f"the effective selector gate rejects minimumSelectorVersion="
|
||||
f"{EMAC_BUNDLE_MIN_SELECTOR_VERSION}; the private selector bundle is stale. "
|
||||
f"Rebuild it from BOTH iqpilot/models_private_src/helpers.py "
|
||||
f"(CURRENT_SELECTOR_VERSION) and fetcher.py (MANIFEST_VERSION)."
|
||||
)
|
||||
|
||||
|
||||
def test_gate_still_accepts_older_bundles():
|
||||
# the window is a range, not a floor: bumping it must not orphan the existing catalogue
|
||||
assert is_bundle_version_compatible({"minimumSelectorVersion": 12})
|
||||
assert is_bundle_version_compatible({"minimumSelectorVersion": 16})
|
||||
|
||||
|
||||
def test_gate_rejects_a_bundle_from_the_future():
|
||||
assert not is_bundle_version_compatible({"minimumSelectorVersion": EMAC_BUNDLE_MIN_SELECTOR_VERSION + 5})
|
||||
131
iqpilot/selfdrive/iqmodeld/tests/test_split_input_state.py
Normal file
131
iqpilot/selfdrive/iqmodeld/tests/test_split_input_state.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
eMac split-model "prepared input equivalence": SplitInputState must reproduce,
|
||||
byte-exact, the queue semantics of compile_split_runtime's execute_bundle —
|
||||
the real tinygrad reference graph run on CPU with stub vision/policy runners,
|
||||
over a multi-frame random sequence with desire rising edges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DEV", "CPU")
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.emac_input_state import EmacInputState, SplitInputState
|
||||
|
||||
N_FRAMES_TEST = 30
|
||||
FRAME_SKIP = 4
|
||||
IMG_SHAPE = (1, 12, 16, 32) # small spatial dims: queue math is shape-generic
|
||||
FB_SHAPE = (1, 25, 512)
|
||||
DP_SHAPE = (1, 25, 8)
|
||||
VISION_OUT_LEN = 1576
|
||||
HIDDEN_SLICE = slice(1064, 1576)
|
||||
|
||||
VISION_SHAPES = {"img": IMG_SHAPE, "big_img": IMG_SHAPE}
|
||||
POLICY_SHAPES = {"desire_pulse": DP_SHAPE, "traffic_convention": (1, 2), "features_buffer": FB_SHAPE}
|
||||
|
||||
|
||||
class _StubRunner:
|
||||
"""Stands in for OnnxRunner inside execute_bundle: returns a preset output
|
||||
and records the materialized inputs it was fed."""
|
||||
|
||||
def __init__(self, out_len: int):
|
||||
self.out_len = out_len
|
||||
self.next_output: np.ndarray | None = None
|
||||
self.captured: dict[str, np.ndarray] | None = None
|
||||
|
||||
def __call__(self, inputs):
|
||||
from tinygrad import Tensor
|
||||
self.captured = {k: v.numpy().copy() for k, v in inputs.items()}
|
||||
out = self.next_output if self.next_output is not None else np.zeros((1, self.out_len), dtype=np.float32)
|
||||
return {"outputs": Tensor(out.astype(np.float32))}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference():
|
||||
from tinygrad import Tensor
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_split_runtime import _role_executor
|
||||
|
||||
meta_by_role = {
|
||||
"vision": {"input_shapes": dict(VISION_SHAPES), "output_slices": {"hidden_state": HIDDEN_SLICE}},
|
||||
"policy": {"input_shapes": dict(POLICY_SHAPES), "output_slices": {}},
|
||||
}
|
||||
vision, policy = _StubRunner(VISION_OUT_LEN), _StubRunner(1000)
|
||||
execute_bundle = _role_executor({"vision": vision, "policy": policy}, meta_by_role, FRAME_SKIP)
|
||||
|
||||
feat_q = Tensor(np.zeros((FRAME_SKIP * (FB_SHAPE[1] - 1) + 1, FB_SHAPE[0], FB_SHAPE[2]), dtype=np.float32),
|
||||
device="CPU").contiguous().realize()
|
||||
desire_q = Tensor(np.zeros((FRAME_SKIP * DP_SHAPE[1], DP_SHAPE[0], DP_SHAPE[2]), dtype=np.float32),
|
||||
device="CPU").contiguous().realize()
|
||||
return execute_bundle, feat_q, desire_q, vision, policy
|
||||
|
||||
|
||||
def test_split_inputs_match_tinygrad_reference(reference):
|
||||
from tinygrad import Tensor
|
||||
|
||||
execute_bundle, feat_q, desire_q, vision_stub, policy_stub = reference
|
||||
rng = np.random.default_rng(4321)
|
||||
state = SplitInputState(FRAME_SKIP, IMG_SHAPE, FB_SHAPE, DP_SHAPE)
|
||||
ref_prev_desire = np.zeros(DP_SHAPE[2], dtype=np.float32)
|
||||
|
||||
for frame in range(N_FRAMES_TEST):
|
||||
warped = rng.integers(0, 256, (2, 6, IMG_SHAPE[2], IMG_SHAPE[3]), dtype=np.int64).astype(np.uint8)
|
||||
raw_desire = np.zeros(DP_SHAPE[2], dtype=np.float32)
|
||||
if frame % 3:
|
||||
raw_desire[int(rng.integers(0, DP_SHAPE[2]))] = 1.0
|
||||
traffic = rng.standard_normal((1, 2)).astype(np.float32)
|
||||
vision_out = rng.standard_normal((1, VISION_OUT_LEN)).astype(np.float32)
|
||||
vision_stub.next_output = vision_out
|
||||
|
||||
# --- ours ---
|
||||
vis_inputs = state.materialize_vision(warped, raw_desire)
|
||||
pol_inputs = state.materialize_policy(vision_out[0, HIDDEN_SLICE], traffic[0])
|
||||
|
||||
# --- reference graph: rising edge happens outside execute_bundle (run_fused) ---
|
||||
cur = raw_desire.copy()
|
||||
cur[0] = 0
|
||||
ref_pulse = np.where(cur - ref_prev_desire > 0.99, cur, 0).astype(np.float32)
|
||||
ref_prev_desire[:] = cur
|
||||
|
||||
execute_bundle(
|
||||
img=Tensor(vis_inputs["img"], device="CPU").realize(),
|
||||
big_img=Tensor(vis_inputs["big_img"], device="CPU").realize(),
|
||||
feat_q=feat_q, desire_q=desire_q,
|
||||
desire=Tensor(ref_pulse, device="CPU").realize(),
|
||||
traffic_convention=Tensor(traffic, device="CPU").realize(),
|
||||
action_t=Tensor(np.zeros((1, 2), dtype=np.float32), device="CPU").realize(),
|
||||
)
|
||||
ref = policy_stub.captured
|
||||
assert ref is not None
|
||||
|
||||
assert ref["features_buffer"].tobytes() == pol_inputs["features_buffer"].tobytes(), f"features frame {frame}"
|
||||
assert ref["desire_pulse"].tobytes() == pol_inputs["desire_pulse"].tobytes(), f"desire frame {frame}"
|
||||
assert ref["traffic_convention"].tobytes() == pol_inputs["traffic_convention"].tobytes()
|
||||
# vision saw exactly what our img queues materialized
|
||||
vref = vision_stub.captured
|
||||
assert vref["img"].tobytes() == vis_inputs["img"].tobytes(), f"img frame {frame}"
|
||||
assert vref["big_img"].tobytes() == vis_inputs["big_img"].tobytes(), f"big_img frame {frame}"
|
||||
|
||||
|
||||
def test_split_img_queue_matches_fused_state():
|
||||
# img/desire mechanics are shared with the fused mirror: same warps must
|
||||
# materialize identical img/big_img in both states
|
||||
rng = np.random.default_rng(7)
|
||||
fused_spec = {
|
||||
"img": (IMG_SHAPE, "uint8"), "big_img": (IMG_SHAPE, "uint8"),
|
||||
"desire_pulse": (DP_SHAPE, "float32"), "traffic_convention": ((1, 2), "float32"),
|
||||
"features_buffer": ((1, 24, 512), "float32"), "action_t": ((1, 2), "float32"),
|
||||
}
|
||||
fused = EmacInputState(FRAME_SKIP, fused_spec)
|
||||
split = SplitInputState(FRAME_SKIP, IMG_SHAPE, FB_SHAPE, DP_SHAPE)
|
||||
for _ in range(12):
|
||||
warped = rng.integers(0, 256, (2, 6, IMG_SHAPE[2], IMG_SHAPE[3]), dtype=np.int64).astype(np.uint8)
|
||||
desire = np.zeros(DP_SHAPE[2], dtype=np.float32)
|
||||
f = fused.push_and_materialize(warped, desire, np.zeros(2, dtype=np.float32), np.zeros(2, dtype=np.float32))
|
||||
s = split.materialize_vision(warped, desire)
|
||||
assert f["img"].tobytes() == s["img"].tobytes()
|
||||
assert f["big_img"].tobytes() == s["big_img"].tobytes()
|
||||
144
iqpilot/selfdrive/iqmodeld/tools/compile_egpu_model.py
Normal file
144
iqpilot/selfdrive/iqmodeld/tools/compile_egpu_model.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pickle
|
||||
import time
|
||||
|
||||
os.environ.setdefault("DEV", "USB+AMD:LLVM")
|
||||
os.environ.setdefault("FLOAT16", "1")
|
||||
os.environ.setdefault("JIT_BATCH_SIZE", "0")
|
||||
os.environ.setdefault("GMMU", "0")
|
||||
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_pkl_path, local_onnx, patch_tinygrad_fetch_fw
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_model import EGPU_MODELS, get_egpu_model, resolve_egpu_model
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import MODEL_INPUT_SPEC, spec_from_meta
|
||||
|
||||
INPUT_SPEC = dict(MODEL_INPUT_SPEC)
|
||||
|
||||
patch_tinygrad_fetch_fw()
|
||||
|
||||
SEED = 42
|
||||
|
||||
|
||||
def set_input_spec(meta: dict) -> None:
|
||||
spec = spec_from_meta(meta)
|
||||
if spec is not None:
|
||||
INPUT_SPEC.clear()
|
||||
INPUT_SPEC.update(spec)
|
||||
|
||||
|
||||
def make_run_model(model_runner):
|
||||
def run_model(**inputs):
|
||||
out = next(iter(model_runner({k: inputs[k] for k in INPUT_SPEC}).values())).cast("float32")
|
||||
return out.reshape(-1),
|
||||
return run_model
|
||||
|
||||
|
||||
def _random_inputs(seed: int):
|
||||
from tinygrad.device import Device
|
||||
from tinygrad.tensor import Tensor
|
||||
rng = np.random.default_rng(seed)
|
||||
out = {}
|
||||
for name, (shape, dtype) in INPUT_SPEC.items():
|
||||
if dtype == "uint8":
|
||||
arr = rng.integers(0, 256, shape).astype(np.uint8)
|
||||
else:
|
||||
arr = rng.standard_normal(shape).astype(np.float32)
|
||||
out[name] = Tensor(arr, device=Device.DEFAULT).realize()
|
||||
return out
|
||||
|
||||
|
||||
def _run(fn, seed: int) -> np.ndarray:
|
||||
from tinygrad.device import Device
|
||||
st = time.perf_counter()
|
||||
outs = fn(**_random_inputs(seed))
|
||||
Device.default.synchronize()
|
||||
print(f" run(seed={seed}) {(time.perf_counter() - st) * 1e3:6.1f} ms")
|
||||
return outs[0].numpy().reshape(-1)
|
||||
|
||||
|
||||
def compile_model(meta: dict, onnx_path: str, out_path: str) -> str:
|
||||
from tinygrad.device import Device
|
||||
from tinygrad.engine.jit import TinyJit
|
||||
from tinygrad.nn.onnx import OnnxRunner
|
||||
|
||||
if meta.get("split"):
|
||||
raise RuntimeError(f"model {meta['key']} is a split model; eGPU v1 compiles fused models only")
|
||||
|
||||
jit = TinyJit(make_run_model(OnnxRunner(onnx_path)), prune=True)
|
||||
|
||||
print("capture + replay")
|
||||
for _ in range(2):
|
||||
baseline = _run(jit, SEED)
|
||||
if baseline.shape[0] != meta["output_len"]:
|
||||
raise RuntimeError(f"model output length {baseline.shape[0]} != registry {meta['output_len']}")
|
||||
if not np.isfinite(baseline).all():
|
||||
raise RuntimeError("compiled model produced non-finite outputs")
|
||||
|
||||
print("pickle round trip")
|
||||
jit = pickle.loads(pickle.dumps(jit))
|
||||
if not np.array_equal(_run(jit, SEED), baseline):
|
||||
raise RuntimeError("outputs differ from baseline after pickle round trip")
|
||||
if np.array_equal(_run(jit, SEED + 1), baseline):
|
||||
raise RuntimeError("outputs insensitive to inputs after pickle round trip")
|
||||
|
||||
from tinygrad.tensor import Tensor
|
||||
zeros = {name: Tensor(np.zeros(shape, dtype=dtype), device=Device.DEFAULT).realize()
|
||||
for name, (shape, dtype) in INPUT_SPEC.items()}
|
||||
flat = jit(**zeros)[0].numpy().reshape(-1)
|
||||
from iqpilot.selfdrive.iqmodeld.parser import PhaseParser
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_supercombo import _slice_outputs, _validate_pose_outputs
|
||||
_validate_pose_outputs(PhaseParser().parse_vision_outputs(_slice_outputs(flat, meta["output_slices"])))
|
||||
|
||||
bundle = {
|
||||
"run_model": jit,
|
||||
"model_key": meta["key"],
|
||||
"model_sha256": meta["sha256"],
|
||||
"output_len": int(meta["output_len"]),
|
||||
"frame_skip": int(meta["frame_skip"]),
|
||||
"input_spec": {name: (tuple(shape), dtype) for name, (shape, dtype) in INPUT_SPEC.items()},
|
||||
"input_device": Device.DEFAULT,
|
||||
}
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
tmp = out_path + ".part"
|
||||
with open(tmp, "wb") as f:
|
||||
pickle.dump(bundle, f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
os.replace(tmp, out_path)
|
||||
return out_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--model", default=None, help=f"registry key, one of {sorted(EGPU_MODELS)}")
|
||||
p.add_argument("--onnx", default=None)
|
||||
p.add_argument("--output", default=None)
|
||||
args = p.parse_args()
|
||||
|
||||
if args.model is not None:
|
||||
if args.model in EGPU_MODELS:
|
||||
meta = get_egpu_model(args.model)
|
||||
else:
|
||||
from iqpilot.common.params import Params
|
||||
meta = resolve_egpu_model(Params(), args.model)
|
||||
if meta is None:
|
||||
raise SystemExit(f"unknown model {args.model!r}: not a built-in ({sorted(EGPU_MODELS)}) and not in the synced catalog")
|
||||
else:
|
||||
meta = get_egpu_model()
|
||||
set_input_spec(meta)
|
||||
|
||||
onnx_path = args.onnx or local_onnx(meta)
|
||||
if onnx_path is None or not os.path.isfile(onnx_path):
|
||||
raise SystemExit(f"onnx not found for {meta['key']}; pass --onnx or let iqegpumodeld download it first")
|
||||
|
||||
out = compile_model(meta, onnx_path, args.output or egpu_pkl_path(meta))
|
||||
print(f"saved eGPU jit to {out} ({os.path.getsize(out) / 1e6:.2f} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
iqpilot/selfdrive/iqmodeld/tools/compile_emac_warp.py
Normal file
9
iqpilot/selfdrive/iqmodeld/tools/compile_emac_warp.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_warp import MODEL_SIZE, compile_warp, main
|
||||
|
||||
__all__ = ["MODEL_SIZE", "compile_warp", "main"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
iqpilot/selfdrive/iqmodeld/tools/compile_warp.py
Normal file
119
iqpilot/selfdrive/iqmodeld/tools/compile_warp.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
Compile the backend-neutral warp-only artifact: NV12 camera frames + 3x3
|
||||
transforms -> (2, 6, model_h/2, model_w/2) uint8 warped tensor, on the device
|
||||
GPU (QCOM). maciqmodeld runs this locally
|
||||
and feed the output to their backend, so the big model's image pipeline is
|
||||
bit-identical to comma's fused pkl warp stage.
|
||||
|
||||
Run ON the device (needs the QCOM backend):
|
||||
cd /data/openpilot && DEV=QCOM WARP_DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 \
|
||||
python3 iqpilot/selfdrive/iqmodeld/tools/compile_warp.py \
|
||||
--camera-resolutions 1928x1208 --output /data/models/emac_warp.pkl
|
||||
The artifact is then split per-resolution into Paths.model_root().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import pickle
|
||||
from functools import partial
|
||||
|
||||
import numpy as np
|
||||
|
||||
SELFTEST_SEED = 20260817
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.temporal_state import DEFAULT_FRAME_SKIP, MODEL_INPUT_SPEC
|
||||
from iqpilot.selfdrive.iqmodeld.tools.compile_supercombo import (
|
||||
NV12Frame, WARP_INPUTS, compile_jit, make_random_images, make_warp, make_warp_input_queues,
|
||||
)
|
||||
|
||||
MODEL_SIZE = (MODEL_INPUT_SPEC["img"][0][3] * 2, MODEL_INPUT_SPEC["img"][0][2] * 2) # (512, 256)
|
||||
|
||||
|
||||
def _parse_size(s: str) -> tuple[int, int]:
|
||||
w, h = s.lower().split("x")
|
||||
return int(w), int(h)
|
||||
|
||||
|
||||
def compile_warp(cam_w: int, cam_h: int, out_path: str | None = None,
|
||||
frame_skip: int = DEFAULT_FRAME_SKIP) -> str:
|
||||
"""Compile the warp-only QCOM JIT for one camera resolution and write the pkl.
|
||||
Returns the artifact path. Callable from the workers so a fresh device
|
||||
self-provisions the warp instead of erroring — needs the QCOM backend."""
|
||||
# the QCOM warp env must be set before tinygrad is imported here
|
||||
os.environ.setdefault("DEV", "QCOM")
|
||||
os.environ.setdefault("WARP_DEV", "QCOM")
|
||||
os.environ.setdefault("IMAGE", "1")
|
||||
os.environ.setdefault("FLOAT16", "1")
|
||||
os.environ.setdefault("NOLOCALS", "1")
|
||||
os.environ.setdefault("JIT_BATCH_SIZE", "0")
|
||||
from tinygrad.engine.jit import TinyJit
|
||||
from iqpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
|
||||
model_w, model_h = MODEL_SIZE
|
||||
input_shapes = {name: shape for name, (shape, _) in MODEL_INPUT_SPEC.items()}
|
||||
nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h))
|
||||
make_random_warp_inputs = partial(make_random_images, keys=["frame", "big_frame"],
|
||||
shape=nv12.size, device=os.getenv("WARP_DEV"))
|
||||
warp_jit = TinyJit(make_warp(nv12, model_w, model_h, frame_skip), prune=True)
|
||||
make_warp_queues = partial(make_warp_input_queues, input_shapes, frame_skip)
|
||||
compiled = compile_jit(warp_jit, make_random_warp_inputs, WARP_INPUTS, make_warp_queues)
|
||||
|
||||
# historical artifact name: already-provisioned devices keep their warp
|
||||
out_path = out_path or os.path.join(Paths.model_root(), f"emac_warp_{cam_w}x{cam_h}_tinygrad.pkl")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
tmp = out_path + ".part"
|
||||
bundle = {(cam_w, cam_h): compiled, "frame_skip": frame_skip, "model_size": MODEL_SIZE}
|
||||
bundle["selftest"] = selftest_digest(compiled, cam_w, cam_h, nv12.size)
|
||||
with open(tmp, "wb") as f:
|
||||
pickle.dump(bundle, f)
|
||||
os.replace(tmp, out_path) # atomic: a reader never sees a half-written pkl
|
||||
return out_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--camera-resolutions", type=_parse_size, nargs="+", default=[(1928, 1208)])
|
||||
p.add_argument("--output", default=None)
|
||||
p.add_argument("--frame-skip", type=int, default=DEFAULT_FRAME_SKIP)
|
||||
args = p.parse_args()
|
||||
for cam_w, cam_h in args.camera_resolutions:
|
||||
out = compile_warp(cam_w, cam_h, args.output, frame_skip=args.frame_skip)
|
||||
print(f"saved warp JIT to {out} ({os.path.getsize(out) / 1e6:.2f} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
def selftest_inputs(cam_w: int, cam_h: int, nv12_size: int):
|
||||
"""A fixed synthetic frame pair and pair of matrices. Deterministic so the
|
||||
digest is reproducible on the device that compiled the artifact."""
|
||||
rng = np.random.default_rng(SELFTEST_SEED)
|
||||
frame = rng.integers(0, 256, nv12_size, dtype=np.uint8)
|
||||
big_frame = rng.integers(0, 256, nv12_size, dtype=np.uint8)
|
||||
tfm = np.array([[0.7, 0.02, 300.0], [0.01, 0.7, 240.0], [0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
big_tfm = np.array([[0.5, 0.01, 380.0], [0.02, 0.5, 300.0], [0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
return frame, big_frame, tfm, big_tfm
|
||||
|
||||
|
||||
def selftest_digest(compiled, cam_w: int, cam_h: int, nv12_size: int) -> str:
|
||||
"""Hash the warp's output for a fixed input.
|
||||
|
||||
A warp artifact pinned to one tinygrad can still unpickle under another and
|
||||
then compute silently wrong, which reaches the model as a garbage image and
|
||||
looks like a bad model rather than a stale artifact. A version string cannot
|
||||
see that; running it can."""
|
||||
from tinygrad.tensor import Tensor
|
||||
frame, big_frame, tfm, big_tfm = selftest_inputs(cam_w, cam_h, nv12_size)
|
||||
dev = os.getenv("WARP_DEV") or "QCOM"
|
||||
out = compiled(tfm=Tensor(tfm, device="NPY").realize(),
|
||||
big_tfm=Tensor(big_tfm, device="NPY").realize(),
|
||||
frame=Tensor(frame, device=dev).realize(),
|
||||
big_frame=Tensor(big_frame, device=dev).realize())
|
||||
return hashlib.sha256(out.numpy().astype(np.uint8).tobytes()).hexdigest()
|
||||
@@ -54,5 +54,33 @@
|
||||
"text": "<b>Unsupported branch!</b> - The current version of <b><u>%1</u></b> is not marked as compatible with the Comma Three (3|tici). Please go to <b>[Device > Software]</b> and install a supported branch such as <b><u>release</u></b> or <b><u>beta</u></b> for the comma three.",
|
||||
"severity": 1,
|
||||
"_comment": "Set extra field to the current branch name."
|
||||
},
|
||||
"Offroad_EgpuNotDetected": {
|
||||
"text": "eGPU dock not detected. Check USB and 12V connections.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuFansObstructed": {
|
||||
"text": "eGPU dock fans obstructed. Check the fans.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuOverheated": {
|
||||
"text": "eGPU dock overheated. Allow it to cool.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuPcieUnavailable": {
|
||||
"text": "eGPU dock PCIe unavailable. %1",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuUncompiled": {
|
||||
"text": "eGPU big model not compiled. Keep ignition on and reboot the device.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuUpdateFailed": {
|
||||
"text": "eGPU dock update failed. Check the USB cable.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_EgpuUsbSlow": {
|
||||
"text": "eGPU dock USB link is slow. Check the USB cable. The current speed is %1.",
|
||||
"severity": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,15 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
|
||||
"Ensure road ahead is clear"),
|
||||
},
|
||||
|
||||
EventName.bigModelLoading: {
|
||||
ET.NO_ENTRY: NoEntryAlert("Big Model Loading"),
|
||||
},
|
||||
|
||||
EventName.bigModelFailed: {
|
||||
ET.SOFT_DISABLE: soft_disable_alert("Big Model Failed"),
|
||||
ET.PERMANENT: NormalPermanentAlert("Big Model Failed ", "Restart the car to retry,\nsmall model is still available", duration=20.),
|
||||
},
|
||||
|
||||
EventName.lateralManeuver: {
|
||||
ET.WARNING: longitudinal_maneuver_alert,
|
||||
ET.PERMANENT: NormalPermanentAlert("Lateral Maneuver Mode"),
|
||||
|
||||
@@ -143,6 +143,9 @@ class SelfdriveD(GapButtonActions):
|
||||
self.events = Events()
|
||||
|
||||
self.initialized = False
|
||||
self.big_model_loading = False
|
||||
self.big_model_active = False
|
||||
self.big_model_failed = False
|
||||
self.enabled = False
|
||||
self.active = False
|
||||
self.mismatch_counter = 0
|
||||
@@ -270,6 +273,28 @@ class SelfdriveD(GapButtonActions):
|
||||
self.events.add(EventName.joystickDebug)
|
||||
self.startup_event = None
|
||||
|
||||
if self.sm['deviceState'].egpuDockPresent or self.params.get_bool("IQEgpuEnabled") or self.big_model_active:
|
||||
loading = self.params.get_bool("UsbGpuLoading")
|
||||
self.big_model_loading = loading
|
||||
if self.big_model_loading:
|
||||
self.events.add(EventName.bigModelLoading)
|
||||
|
||||
big_active = self.params.get("UsbGpuActive")
|
||||
dock_present = self.sm['deviceState'].egpuDockPresent
|
||||
mac_active = self.params.get_bool("MacModelActive")
|
||||
model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2']
|
||||
big_failed = (big_active is False or model_unavailable
|
||||
or (self.big_model_active and not dock_present)) and not mac_active
|
||||
if big_failed:
|
||||
self.events.add(EventName.bigModelFailed)
|
||||
self.big_model_failed = big_failed
|
||||
|
||||
# soft disable if the big model fails
|
||||
if big_active:
|
||||
self.big_model_active = True
|
||||
if mac_active or (not self.enabled and not model_unavailable):
|
||||
self.big_model_active = False
|
||||
|
||||
if self.sm.recv_frame['lateralManeuverPlan'] > 0:
|
||||
self.events.add(EventName.lateralManeuver)
|
||||
self.startup_event = None
|
||||
|
||||
@@ -135,7 +135,9 @@ def migrate_drivingModelData(msgs):
|
||||
setattr(dmd.drivingModelData, field, getattr(msg.modelV2, field))
|
||||
for meta_field in ["laneChangeState", "laneChangeState"]:
|
||||
setattr(dmd.drivingModelData.meta, meta_field, getattr(msg.modelV2.meta, meta_field))
|
||||
if len(msg.modelV2.laneLines) and len(msg.modelV2.laneLineProbs):
|
||||
lane_lines = msg.modelV2.laneLines
|
||||
lane_probs = msg.modelV2.laneLineProbs
|
||||
if len(lane_lines) > 2 and len(lane_probs) > 2 and len(lane_lines[1].y) and len(lane_lines[2].y):
|
||||
fill_lane_line_meta(dmd.drivingModelData.laneLineMeta, msg.modelV2.laneLines, msg.modelV2.laneLineProbs)
|
||||
if all(len(a) for a in [msg.modelV2.position.x, msg.modelV2.position.y, msg.modelV2.position.z]):
|
||||
fill_xyz_poly(dmd.drivingModelData.path, ModelConstants.POLY_PATH_DEGREE, msg.modelV2.position.x, msg.modelV2.position.y, msg.modelV2.position.z)
|
||||
|
||||
19
iqpilot/selfdrive/test/process_replay/test_migration.py
Normal file
19
iqpilot/selfdrive/test/process_replay/test_migration.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from iqpilot.cereal import messaging
|
||||
from iqpilot.selfdrive.test.process_replay.migration import migrate_drivingModelData
|
||||
|
||||
|
||||
def test_driving_model_migration_ignores_incomplete_lane_metadata():
|
||||
msg = messaging.new_message("modelV2")
|
||||
msg.modelV2.init("laneLines", 4)
|
||||
for lane_line in msg.modelV2.laneLines:
|
||||
lane_line.y = [1.0]
|
||||
msg.modelV2.laneLineProbs = [0.5]
|
||||
|
||||
_, added, _ = migrate_drivingModelData([(0, msg.as_reader())])
|
||||
|
||||
assert len(added) == 1
|
||||
assert added[0].drivingModelData.laneLineMeta.leftProb == 0.0
|
||||
assert added[0].drivingModelData.laneLineMeta.rightProb == 0.0
|
||||
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
47
iqpilot/system/hardware/egpu_dock/TESTING.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# eGPU dock bring-up runbook
|
||||
|
||||
Our flasher is a port of comma's known-working one, byte-identical firmware
|
||||
bundle, but it has never touched real hardware. Order matters: everything
|
||||
read-only first, evidence at every step.
|
||||
|
||||
Run everything as root from the repo root on the device, dock on the USB-C
|
||||
port, car ignition off.
|
||||
|
||||
## 1. Read-only probe (safe, run first, share the output)
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/dock_probe.py
|
||||
|
||||
Expected on a dock previously flashed by stock openpilot: product matches the
|
||||
bundled `custom ed4e39b7-CLEAN`, USB3 speed, PCIe link L0, stable config read.
|
||||
Any other result: stop and send the output before proceeding.
|
||||
|
||||
## 2. Flash-path validation (writes, but writes the same bytes)
|
||||
|
||||
A stock-flashed dock already runs our exact bundled firmware, so the
|
||||
no-op path proves version detection:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py
|
||||
|
||||
Expected: "firmware matches" and no write. Then exercise the full write path
|
||||
by reflashing the identical image:
|
||||
|
||||
sudo python3 iqpilot/system/hardware/egpu_dock/flash.py --force
|
||||
|
||||
This backs up the per-unit config page to /data/egpu_dock_config/ first and
|
||||
verifies every sector; identical bytes make it the lowest-risk possible
|
||||
full-path test. Re-run step 1 after; product string and config sha must be
|
||||
unchanged.
|
||||
|
||||
## 3. Runtime
|
||||
|
||||
Set `IQEgpuEnabled`, go onroad (bench is fine), and confirm iqegpumodeld
|
||||
downloads/compiles and the selector reports UsbGpu* status. The runtime gate
|
||||
requires the exact bundled firmware product string, so a dock that failed
|
||||
step 2 will be treated as absent by design.
|
||||
|
||||
## If anything goes wrong
|
||||
|
||||
The dock falling back to the ROM bootloader (product "USB 3.2 PCIe
|
||||
TinyEnclosure" or AS2462*) is recoverable: flash.py handles ROM recovery, and
|
||||
the config backup from step 2 is on disk. Do not improvise register writes;
|
||||
capture output and stop.
|
||||
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
0
iqpilot/system/hardware/egpu_dock/__init__.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
46
iqpilot/system/hardware/egpu_dock/dock_probe.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
from iqpilot.system.hardware.egpu_dock.flash import (
|
||||
Flash, RomFallback, bundled_version, find_dock, in_rom_bootloader, link_up, stable_read,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock enumerated")
|
||||
return 1
|
||||
print(f"dock at {path}")
|
||||
print(f" vid:pid {vid_pid[0]}:{vid_pid[1]}")
|
||||
print(f" product {product!r}")
|
||||
print(f" bundled {bundled_version()!r}")
|
||||
print(f" match {product == bundled_version()}")
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
print(f" usb speed {speed} Mbps ({'USB3' if speed >= 5000 else 'USB2 - register reads capped at 64B'})")
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
print(" state ROM bootloader (config page lost or firmware invalid)")
|
||||
return 2
|
||||
print(f" pcie link {'L0 (trained)' if link_up() else 'not trained'}")
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
flash.connect()
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
print(f" config sha256={hashlib.sha256(config).hexdigest()[:16]} "
|
||||
f"(stable over 3 reads, {sum(1 for b in config if b != 0xFF)} non-blank bytes)")
|
||||
except (RomFallback, OSError, RuntimeError, TimeoutError) as e:
|
||||
print(f" config read failed: {type(e).__name__}: {e}")
|
||||
return 3
|
||||
finally:
|
||||
flash.close()
|
||||
print("all read-only checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
BIN
iqpilot/system/hardware/egpu_dock/firmware_wrapped.bin
Normal file
Binary file not shown.
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
617
iqpilot/system/hardware/egpu_dock/flash.py
Normal file
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
VID_PIDS = (("add1", "0001"), ("3801", "0001"))
|
||||
ROM_VID_PIDS = (("174c", "2464"), ("174c", "2463"))
|
||||
ROM_PRODUCT = "USB 3.2 PCIe TinyEnclosure"
|
||||
FIRMWARE_PATH = Path(__file__).with_name("firmware_wrapped.bin")
|
||||
CONFIG_DIR = "/data/egpu_dock_config"
|
||||
LEGACY_CONFIG_DIR = "/data/chestnut_config"
|
||||
PM_PATHS = ("/sys/bus/platform/devices/a800000.ssusb", "/sys/bus/platform/devices/a600000.ssusb",
|
||||
"/sys/bus/usb/devices/usb4")
|
||||
VBUS_PATH = "/sys/kernel/debug/regulator/smb2-vbus/enable"
|
||||
IMAGE_OFFSET = 0x100
|
||||
SECTOR, PAGE = 4096, 128
|
||||
MAX_REGISTER_READ_SIZE = 255
|
||||
MAX_CODE_SIZE = 0x10000
|
||||
FLASH_BUDGET = 600.0
|
||||
USBDEVFS_CONTROL = 0xC0185500
|
||||
USBDEVFS_BULK = 0xC0185502
|
||||
USBDEVFS_SETINTERFACE = 0x80085504
|
||||
USBDEVFS_SETCONFIGURATION = 0x80045505
|
||||
USBDEVFS_CLAIMINTERFACE = 0x8004550F
|
||||
USBDEVFS_RESET = 0x5514
|
||||
USBDEVFS_CLEAR_HALT = 0x80045515
|
||||
|
||||
_deadline = float("inf")
|
||||
|
||||
|
||||
def check_budget():
|
||||
if time.monotonic() > _deadline:
|
||||
raise TimeoutError(f"flash did not converge within {FLASH_BUDGET:g}s")
|
||||
|
||||
|
||||
class Ctrl(ctypes.Structure):
|
||||
_fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8),
|
||||
("value", ctypes.c_uint16), ("index", ctypes.c_uint16),
|
||||
("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32),
|
||||
("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class Bulk(ctypes.Structure):
|
||||
_fields_ = [("ep", ctypes.c_uint), ("len", ctypes.c_uint),
|
||||
("timeout", ctypes.c_uint), ("data", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class RomFallback(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_dock():
|
||||
found = []
|
||||
for d in glob.glob("/sys/bus/usb/devices/*"):
|
||||
try:
|
||||
with open(d + "/idVendor") as fv, open(d + "/idProduct") as fp:
|
||||
vid_pid = (fv.read().strip(), fp.read().strip())
|
||||
if vid_pid in VID_PIDS + ROM_VID_PIDS:
|
||||
with open(d + "/product") as fpr:
|
||||
found.append((d, vid_pid, fpr.read().strip()))
|
||||
except OSError:
|
||||
pass
|
||||
if len(found) > 1:
|
||||
raise RuntimeError(f"expected one eGPU dock, found {len(found)}")
|
||||
return found[0] if found else (None, None, None)
|
||||
|
||||
|
||||
def in_rom_bootloader(vid_pid, product):
|
||||
return vid_pid in ROM_VID_PIDS or product == ROM_PRODUCT or (product or "").startswith("AS2462")
|
||||
|
||||
|
||||
def disable_runtime_pm(path):
|
||||
control = os.path.join(path, "power/control")
|
||||
if not os.path.exists(control):
|
||||
return
|
||||
with open(control, "w") as f:
|
||||
f.write("on\n")
|
||||
with open(control) as fh:
|
||||
applied = fh.read().strip()
|
||||
if applied != "on":
|
||||
raise RuntimeError(f"could not disable USB runtime PM: {control}")
|
||||
delay = os.path.join(path, "power/autosuspend_delay_ms")
|
||||
if os.path.exists(delay):
|
||||
with open(delay, "w") as f:
|
||||
f.write("-1\n")
|
||||
|
||||
|
||||
def unbind_drivers(path):
|
||||
for interface in glob.glob(path + ":*"):
|
||||
driver = interface + "/driver"
|
||||
if os.path.islink(driver):
|
||||
with open(os.path.realpath(driver) + "/unbind", "w") as f:
|
||||
f.write(os.path.basename(interface))
|
||||
|
||||
|
||||
def open_device(path):
|
||||
with open(path + "/busnum") as fb, open(path + "/devnum") as fd_:
|
||||
bus, dev = int(fb.read()), int(fd_.read())
|
||||
return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR)
|
||||
|
||||
|
||||
def link_up() -> bool:
|
||||
try:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
return False
|
||||
fd = open_device(path)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0x40, 0xF3, 1, 0, 0, 2000, None))
|
||||
buf = (ctypes.c_ubyte * 1)()
|
||||
fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf[0] == 0x78
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def claim_interface(path, setup=False):
|
||||
disable_runtime_pm(path)
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETCONFIGURATION, struct.pack("I", 1))
|
||||
fcntl.ioctl(fd, USBDEVFS_CLAIMINTERFACE, struct.pack("I", 0))
|
||||
if setup:
|
||||
fcntl.ioctl(fd, USBDEVFS_SETINTERFACE, struct.pack("II", 0, 0))
|
||||
except OSError as e:
|
||||
os.close(fd)
|
||||
if e.errno == errno.EBUSY:
|
||||
raise RuntimeError("eGPU dock is in use, stop the model/GPU processes before flashing") from e
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
class Flash:
|
||||
def __init__(self):
|
||||
self.fd = -1
|
||||
self.max_register_read_size = MAX_REGISTER_READ_SIZE
|
||||
|
||||
def close(self):
|
||||
if self.fd >= 0:
|
||||
os.close(self.fd)
|
||||
self.fd = -1
|
||||
|
||||
def connect(self, timeout=5.0):
|
||||
self.close()
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
path, vid_pid, product = find_dock()
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
raise RomFallback("eGPU dock fell back to the ROM bootloader")
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path + "/speed") as fs:
|
||||
speed = int(fs.read())
|
||||
except (OSError, ValueError):
|
||||
speed = 0
|
||||
self.max_register_read_size = 64 if speed < 5000 else MAX_REGISTER_READ_SIZE
|
||||
self.fd = claim_interface(path)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"eGPU dock did not enumerate within {timeout:g}s")
|
||||
|
||||
def reg_write(self, addr, value):
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0x40, 0xE5, addr & 0xFFFF, value & 0xFFFF, 0, 2000, None))
|
||||
|
||||
def reg_read(self, addr, length=1):
|
||||
buf = (ctypes.c_ubyte * length)()
|
||||
fcntl.ioctl(self.fd, USBDEVFS_CONTROL,
|
||||
Ctrl(0xC0, 0xE4, addr & 0xFFFF, 0, length, 2000, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return bytes(buf)
|
||||
|
||||
def write_buffer(self, data):
|
||||
for i, value in enumerate(data):
|
||||
self.reg_write(0x7000 + i, value)
|
||||
|
||||
def wait_controller(self, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.reg_read(0xC8A9)[0] & 1:
|
||||
return
|
||||
raise TimeoutError("flash controller timeout")
|
||||
|
||||
def transaction(self, command, addr=0, length=0, addr_len=0x07, mode=0):
|
||||
for reg, value in ((0xC8AD, mode), (0xC8AE, 0), (0xC8AF, 0), (0xC8AA, command), (0xC8AC, addr_len),
|
||||
(0xC8A1, addr), (0xC8A2, addr >> 8), (0xC8AB, addr >> 16), (0xC8A3, length >> 8), (0xC8A4, length)):
|
||||
self.reg_write(reg, value & 0xFF)
|
||||
self.reg_write(0xC8A9, 1)
|
||||
self.wait_controller()
|
||||
for _ in range(4):
|
||||
self.reg_write(0xC8AD, 0)
|
||||
|
||||
def write_enable(self):
|
||||
for reg, value in ((0xC8AD, 0), (0xC8AA, 0x06), (0xC8AC, 0x04), (0xC8A3, 0), (0xC8A4, 0), (0xC8A9, 1)):
|
||||
self.reg_write(reg, value)
|
||||
self.wait_controller()
|
||||
|
||||
def status(self):
|
||||
self.transaction(0x05, length=1, addr_len=0x04)
|
||||
return self.reg_read(0x7000)[0]
|
||||
|
||||
def wait_write_done(self, timeout=10.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not self.status() & 1:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise TimeoutError("SPI flash WIP timeout")
|
||||
|
||||
def init(self):
|
||||
self.reg_write(0xCC33, 0x04)
|
||||
self.reg_write(0xCA81, self.reg_read(0xCA81)[0] | 1)
|
||||
self.reg_write(0xC805, 0x02)
|
||||
self.reg_write(0xC8A6, 0x04)
|
||||
for _ in range(5):
|
||||
self.write_enable()
|
||||
self.write_buffer(bytes(4))
|
||||
self.transaction(0x01, length=1, addr_len=0x04, mode=1)
|
||||
time.sleep(0.01)
|
||||
if not self.status() & 0x1C:
|
||||
return
|
||||
raise RuntimeError("could not clear SPI block protection")
|
||||
|
||||
def read(self, addr, length):
|
||||
out = bytearray()
|
||||
while len(out) < length:
|
||||
n = min(4096, length - len(out))
|
||||
self.transaction(0x03, addr + len(out), max(4096, n))
|
||||
for off in range(0, n, self.max_register_read_size):
|
||||
out += self.reg_read(0x7000 + off, min(self.max_register_read_size, n - off))
|
||||
return bytes(out)
|
||||
|
||||
def erase_sector(self, addr):
|
||||
self.write_enable()
|
||||
self.transaction(0x20, addr)
|
||||
self.wait_write_done()
|
||||
|
||||
def program(self, addr, data):
|
||||
self.write_buffer(data + bytes((-len(data)) % 4))
|
||||
self.write_enable()
|
||||
self.transaction(0x02, addr, len(data), mode=1)
|
||||
self.wait_write_done()
|
||||
|
||||
|
||||
def validate_image(data):
|
||||
if len(data) < 10:
|
||||
raise ValueError("wrapped firmware is too short")
|
||||
body_len = int.from_bytes(data[:4], "little")
|
||||
if body_len > MAX_CODE_SIZE:
|
||||
raise ValueError(f"wrapped firmware body exceeds {MAX_CODE_SIZE} bytes")
|
||||
if len(data) != body_len + 10 or data[4 + body_len] != 0xA5:
|
||||
raise ValueError("invalid wrapped firmware length or magic")
|
||||
body = data[4:4 + body_len]
|
||||
if data[5 + body_len] != sum(body) & 0xFF:
|
||||
raise ValueError("invalid wrapped firmware checksum")
|
||||
if data[6 + body_len:] != zlib.crc32(body).to_bytes(4, "little"):
|
||||
raise ValueError("invalid wrapped firmware CRC")
|
||||
|
||||
|
||||
def image_product(image):
|
||||
match = re.search(rb"custom [0-9a-f]{8}-CLEAN", image)
|
||||
if match is None:
|
||||
raise ValueError("no product string in wrapped firmware")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
def reconnect(flash):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
check_budget()
|
||||
try:
|
||||
flash.connect()
|
||||
flash.init()
|
||||
return
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"waiting for eGPU dock (attempt {attempt}): {e}", flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def with_retries(flash, label, operation):
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
return operation()
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
check_budget()
|
||||
print(f"{label} attempt {attempt}: {e}", flush=True)
|
||||
reconnect(flash)
|
||||
|
||||
|
||||
def stable_read(flash, addr, length, count=2):
|
||||
def read():
|
||||
reads = [flash.read(addr, length) for _ in range(count)]
|
||||
if any(x != reads[0] for x in reads[1:]):
|
||||
raise RuntimeError(f"unstable flash read at 0x{addr:05x}")
|
||||
return reads[0]
|
||||
return with_retries(flash, f"read 0x{addr:05x}", read)
|
||||
|
||||
|
||||
def program_sector(flash, addr, target):
|
||||
def program():
|
||||
flash.erase_sector(addr)
|
||||
if flash.read(addr, SECTOR) != bytes([0xFF]) * SECTOR:
|
||||
raise RuntimeError("sector erase verification failed")
|
||||
for off in range(0, SECTOR, PAGE):
|
||||
chunk = target[off:off + PAGE]
|
||||
if chunk != bytes([0xFF]) * len(chunk):
|
||||
flash.program(addr + off, chunk)
|
||||
if flash.read(addr + off, len(chunk)) != chunk:
|
||||
raise RuntimeError(f"page verify failed at 0x{addr + off:05x}")
|
||||
if flash.read(addr, SECTOR) != target:
|
||||
raise RuntimeError("sector verification failed")
|
||||
with_retries(flash, f"sector 0x{addr:05x}", program)
|
||||
|
||||
|
||||
def config_path():
|
||||
return os.path.join(CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def legacy_config_path():
|
||||
return os.path.join(LEGACY_CONFIG_DIR, f"{os.uname().nodename}.bin")
|
||||
|
||||
|
||||
def saved_config(path, data):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError as e:
|
||||
with open(path, "rb") as fh:
|
||||
backup = fh.read()
|
||||
if len(backup) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {path}") from e
|
||||
if backup != data:
|
||||
print(f"restoring config from {path}", flush=True)
|
||||
return backup
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
return data
|
||||
|
||||
|
||||
def rom_write(image, config):
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock disappeared before recovery")
|
||||
unbind_drivers(path)
|
||||
fd = open_device(path)
|
||||
try:
|
||||
fcntl.ioctl(fd, USBDEVFS_RESET)
|
||||
finally:
|
||||
os.close(fd)
|
||||
time.sleep(3)
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
raise RuntimeError("eGPU dock did not re-enumerate after reset")
|
||||
fd = claim_interface(path, setup=True)
|
||||
for ep in (0x02, 0x81):
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", ep))
|
||||
tag = 0
|
||||
|
||||
def bulk(ep, payload, timeout):
|
||||
buf = ctypes.create_string_buffer(bytes(payload), len(payload))
|
||||
fcntl.ioctl(fd, USBDEVFS_BULK, Bulk(ep, len(payload), timeout, ctypes.cast(buf, ctypes.c_void_p)))
|
||||
return buf.raw
|
||||
|
||||
def cmd(cdb, data=b"", timeout=30000):
|
||||
nonlocal tag
|
||||
tag += 1
|
||||
bulk(0x02, struct.pack("<IIIBBB16s", 0x43425355, tag, len(data), 0, 0, len(cdb), cdb), timeout)
|
||||
if data:
|
||||
bulk(0x02, data, timeout)
|
||||
try:
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EPIPE:
|
||||
raise
|
||||
fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", 0x81))
|
||||
csw = bulk(0x81, bytes(13), timeout)
|
||||
if csw[:4] != b"USBS" or csw[12] != 0:
|
||||
raise RuntimeError(f"ROM flash command {cdb[0]:02x} {cdb[1]:02x} failed")
|
||||
|
||||
print("recovering from the ROM bootloader", flush=True)
|
||||
try:
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 0), config[:0x80])
|
||||
cmd(struct.pack(">BBB12x", 0xE1, 0x50, 1), config[0x80:])
|
||||
cmd(struct.pack(">BBI", 0xE3, 0x50, min(len(image), 0xFF00)), image[:0xFF00])
|
||||
if len(image) > 0xFF00:
|
||||
cmd(struct.pack(">BBI", 0xE3, 0xD0, len(image) - 0xFF00), image[0xFF00:])
|
||||
cmd(struct.pack(">BB13x", 0xE8, 0x51))
|
||||
finally:
|
||||
os.close(fd)
|
||||
print("recovery flash done", flush=True)
|
||||
|
||||
|
||||
def vbus_write(value):
|
||||
try:
|
||||
with open(VBUS_PATH, "w") as f:
|
||||
f.write(value + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def vbus_cycle():
|
||||
if os.path.exists(VBUS_PATH):
|
||||
vbus_write("0")
|
||||
time.sleep(2)
|
||||
vbus_write("1")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def activate(expected_product):
|
||||
if not os.path.exists(VBUS_PATH):
|
||||
print("no VBUS control, firmware activates on the next dock power cycle", flush=True)
|
||||
return
|
||||
print("power-cycling the eGPU dock VBUS", flush=True)
|
||||
vbus_write("0")
|
||||
disconnected = False
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
path, _, _ = find_dock()
|
||||
if path is None:
|
||||
disconnected = True
|
||||
break
|
||||
time.sleep(0.2)
|
||||
time.sleep(1)
|
||||
vbus_write("1")
|
||||
if not disconnected:
|
||||
print("dock stayed powered, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
deadline = time.monotonic() + 15.0
|
||||
while time.monotonic() < deadline:
|
||||
_, _, product = find_dock()
|
||||
if product is not None:
|
||||
if product == expected_product:
|
||||
print(f"activated {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"dock re-enumerated with {product!r}, firmware activates on its next power cycle", flush=True)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
print("dock did not re-enumerate, firmware activates on its next power cycle", flush=True)
|
||||
|
||||
|
||||
def defer_signal(signum, _frame):
|
||||
os.write(1, f"signal {signum} deferred until the dock is powered back up\n".encode())
|
||||
|
||||
|
||||
def flash_dock(expected_version=None, force=False):
|
||||
global _deadline
|
||||
|
||||
image = FIRMWARE_PATH.read_bytes()
|
||||
validate_image(image)
|
||||
expected_product = image_product(image)
|
||||
if expected_version is not None and expected_product != f"custom {expected_version}-CLEAN":
|
||||
raise RuntimeError(f"bundled firmware is {expected_product!r}, expected version {expected_version}")
|
||||
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
print("no eGPU dock connected", flush=True)
|
||||
return
|
||||
if product == expected_product and not force:
|
||||
print(f"eGPU dock firmware is up to date ({expected_product})", flush=True)
|
||||
return
|
||||
|
||||
_deadline = time.monotonic() + FLASH_BUDGET
|
||||
for pm_path in PM_PATHS:
|
||||
disable_runtime_pm(pm_path)
|
||||
|
||||
previous = {sig: signal.signal(sig, defer_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
||||
try:
|
||||
if in_rom_bootloader(vid_pid, product):
|
||||
if not recover_from_rom(image, expected_product):
|
||||
return
|
||||
force, product = True, None
|
||||
write_image(image, expected_product, product, force)
|
||||
finally:
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
|
||||
def recover_from_rom(image, expected_product):
|
||||
backup = config_path()
|
||||
if not os.path.isfile(backup) and os.path.isfile(legacy_config_path()):
|
||||
backup = legacy_config_path()
|
||||
if not os.path.isfile(backup):
|
||||
raise RuntimeError(f"cannot recover from the ROM bootloader without a config backup at {config_path()}")
|
||||
with open(backup, "rb") as fh:
|
||||
config = fh.read()
|
||||
if len(config) != 0x100:
|
||||
raise RuntimeError(f"invalid config backup: {backup}")
|
||||
|
||||
committed = False
|
||||
while True:
|
||||
check_budget()
|
||||
path, vid_pid, product = find_dock()
|
||||
if path is None:
|
||||
if committed:
|
||||
print("dock is offline, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
vbus_cycle()
|
||||
continue
|
||||
if not in_rom_bootloader(vid_pid, product):
|
||||
return True
|
||||
if committed:
|
||||
print("dock stayed powered, recovered firmware boots on its next power cycle", flush=True)
|
||||
return False
|
||||
try:
|
||||
rom_write(image, config)
|
||||
committed = True
|
||||
except (OSError, TimeoutError, RuntimeError) as e:
|
||||
print(f"ROM recovery failed, retrying: {e}", flush=True)
|
||||
vbus_cycle()
|
||||
continue
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def write_image(image, expected_product, product, force):
|
||||
if force:
|
||||
print(f"forced reflash of {expected_product}", flush=True)
|
||||
else:
|
||||
print(f"eGPU dock firmware mismatch: {product!r}; expected {expected_product!r}", flush=True)
|
||||
|
||||
flash = Flash()
|
||||
try:
|
||||
reconnect(flash)
|
||||
config = stable_read(flash, 0, 0x100, 3)
|
||||
config = saved_config(config_path(), config)
|
||||
image_end = IMAGE_OFFSET + len(image)
|
||||
first_sector = IMAGE_OFFSET & ~(SECTOR - 1)
|
||||
span = (image_end + SECTOR - 1) & ~(SECTOR - 1)
|
||||
current = stable_read(flash, first_sector, span - first_sector)
|
||||
target = bytearray(current)
|
||||
target[:len(config)] = config
|
||||
target[IMAGE_OFFSET - first_sector:image_end - first_sector] = image
|
||||
target = bytes(target)
|
||||
print(f"target {len(image)} bytes at 0x{IMAGE_OFFSET:05x}, sha256={hashlib.sha256(image).hexdigest()}", flush=True)
|
||||
|
||||
if not _still_offroad():
|
||||
raise RuntimeError("device went onroad before any sector was written; aborting flash")
|
||||
|
||||
for addr in range(first_sector, span, SECTOR):
|
||||
off = addr - first_sector
|
||||
wanted = target[off:off + SECTOR]
|
||||
if current[off:off + SECTOR] == wanted:
|
||||
print(f"sector 0x{addr:05x}: unchanged", flush=True)
|
||||
else:
|
||||
print(f"sector 0x{addr:05x}: programming", flush=True)
|
||||
program_sector(flash, addr, wanted)
|
||||
|
||||
verified = stable_read(flash, first_sector, span - first_sector, 3)
|
||||
if verified != target:
|
||||
raise RuntimeError("final full-image verification failed")
|
||||
print(f"verified sha256={hashlib.sha256(verified).hexdigest()}", flush=True)
|
||||
finally:
|
||||
flash.close()
|
||||
|
||||
activate(expected_product)
|
||||
|
||||
|
||||
def bundled_version() -> str:
|
||||
return image_product(FIRMWARE_PATH.read_bytes())
|
||||
|
||||
|
||||
def _still_offroad() -> bool:
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
return bool(Params().get_bool("IsOffroad"))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def dock_needs_flash(usb_devices: list[dict]) -> bool:
|
||||
try:
|
||||
expected = bundled_version()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
ids = tuple(tuple(int(x, 16) for x in p) for p in VID_PIDS + ROM_VID_PIDS)
|
||||
return any((d.get("vendorId"), d.get("productId")) in ids and d.get("product") != expected
|
||||
for d in usb_devices)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="check and flash the bundled eGPU dock firmware")
|
||||
parser.add_argument("version", nargs="?", help="expected firmware version hash")
|
||||
parser.add_argument("--force", action="store_true", help="reflash even when the version matches")
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError("flash.py must run as root")
|
||||
flash_dock(expected_version=args.version, force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
89
iqpilot/system/hardware/egpu_dock/status.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import time
|
||||
|
||||
from iqpilot.system.hardware.usb import EGPU_DOCK_FW_PRODUCT, is_egpu_usb_device
|
||||
|
||||
EGPU_POWERED_VOLTAGE = 5000
|
||||
GPU_TEMP_LIMIT = 110.
|
||||
MEMORY_TEMP_LIMIT = 108.
|
||||
TEMP_HYSTERESIS = 5.
|
||||
FAN_START_GPU_TEMP = 60.
|
||||
FAN_STOP_GPU_TEMP = 50.
|
||||
FAN_START_MEMORY_TEMP = 70.
|
||||
FAN_STOP_MEMORY_TEMP = 60.
|
||||
FAN_STALLED_RPM = 250
|
||||
|
||||
|
||||
class EgpuDockStatus:
|
||||
def __init__(self):
|
||||
self.offroad = True
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.overheated = False
|
||||
self.fans_obstructed = False
|
||||
self.usb_seen = False
|
||||
self.usb_failed = False
|
||||
|
||||
def update(self, offroad, usb_state, firmware_failed, model_loading, model_active, compiled, state, set_alert):
|
||||
detected = [d for d in usb_state if is_egpu_usb_device(d["vendorId"], d["productId"], include_bootloader=True)]
|
||||
devices = [d for d in detected if is_egpu_usb_device(d["vendorId"], d["productId"])]
|
||||
firmware_ok = len(devices) == 1 and devices[0]["product"] == EGPU_DOCK_FW_PRODUCT
|
||||
|
||||
if self.offroad and not offroad:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
self.link_failures = 0
|
||||
self.model_loading_seen = False
|
||||
self.model_attempted = False
|
||||
self.usb_seen = firmware_ok
|
||||
self.usb_failed = False
|
||||
|
||||
self.model_loading_seen |= model_loading
|
||||
self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None
|
||||
|
||||
if not offroad and self.usb_seen and not firmware_ok:
|
||||
self.usb_failed = True
|
||||
|
||||
if not offroad and self.model_attempted and state is not None:
|
||||
power_lost = state.supplyFault or state.supplyVoltage < EGPU_POWERED_VOLTAGE
|
||||
self.link_failures = self.link_failures + 1 if state.pcieLtssm != 0x78 else 0
|
||||
self.pcie_failed |= self.link_failures >= 2 or power_lost
|
||||
self.power_lost |= power_lost
|
||||
|
||||
if self.pcie_failed and self.power_lost and state is not None:
|
||||
self.power_restored |= not state.supplyFault and state.supplyVoltage >= EGPU_POWERED_VOLTAGE
|
||||
if self.usb_failed:
|
||||
self.pcie_failed = False
|
||||
self.power_lost = False
|
||||
self.power_restored = False
|
||||
|
||||
if state is not None:
|
||||
gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
memory_limit = MEMORY_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.)
|
||||
self.overheated = state.tempC >= gpu_limit or state.memoryTempC >= memory_limit
|
||||
fan_hot = (state.tempC >= (FAN_STOP_GPU_TEMP if self.fans_obstructed else FAN_START_GPU_TEMP) or
|
||||
state.memoryTempC >= (FAN_STOP_MEMORY_TEMP if self.fans_obstructed else FAN_START_MEMORY_TEMP))
|
||||
self.fans_obstructed = fan_hot and state.fanSpeedRpm < FAN_STALLED_RPM
|
||||
|
||||
slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000
|
||||
set_alert("Offroad_EgpuNotDetected", self.usb_failed)
|
||||
set_alert("Offroad_EgpuFansObstructed", self.fans_obstructed)
|
||||
set_alert("Offroad_EgpuOverheated", self.overheated)
|
||||
set_alert("Offroad_EgpuUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None)
|
||||
if self.power_lost:
|
||||
pcie_action = "12V power was interrupted, possibly by engine start-stop. "
|
||||
pcie_action += ("Cycle ignition to reload the model." if self.power_restored else
|
||||
"Check 12V, then cycle ignition to reload the model.")
|
||||
else:
|
||||
pcie_action = "Check 12V connection."
|
||||
set_alert("Offroad_EgpuPcieUnavailable", self.pcie_failed, pcie_action)
|
||||
set_alert("Offroad_EgpuUncompiled", offroad and firmware_ok and not compiled)
|
||||
set_alert("Offroad_EgpuUpdateFailed", offroad and firmware_failed)
|
||||
self.offroad = offroad
|
||||
@@ -3,24 +3,13 @@ import numpy as np
|
||||
|
||||
|
||||
class FanController:
|
||||
def __init__(self) -> None:
|
||||
self.last_ignition = False
|
||||
|
||||
def update(self, cur_temp: float, ignition: bool, max_cool: bool = False) -> int:
|
||||
if max_cool:
|
||||
self.last_ignition = ignition
|
||||
return 100
|
||||
|
||||
if cur_temp < 70.0:
|
||||
fan_pwr_out = 0
|
||||
elif cur_temp > 85.0:
|
||||
fan_pwr_out = 100
|
||||
else:
|
||||
# 70°C → 0%, 85°C → 80%, target 75°C
|
||||
fan_pwr_out = int(np.interp(cur_temp, [70.0, 85.0], [0, 80]))
|
||||
fan_pwr_out = int(np.interp(cur_temp, [70.0, 85.0, 90.0], [0, 80, 100]))
|
||||
|
||||
if not ignition:
|
||||
fan_pwr_out = min(fan_pwr_out, 30)
|
||||
|
||||
self.last_ignition = ignition
|
||||
return fan_pwr_out
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import fcntl
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
@@ -16,10 +18,14 @@ from iqpilot.cereal.services import SERVICE_LIST
|
||||
from iqpilot.common.iq_perf import PerfSample, PerfTraceEmitter
|
||||
from iqpilot.common.utils import strip_deprecated_keys
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.realtime import DT_HW
|
||||
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from iqpilot.system.hardware import HARDWARE, TICI, AGNOS
|
||||
from iqpilot.system.hardware.egpu_dock.flash import dock_needs_flash
|
||||
from iqpilot.system.hardware.egpu_dock.status import EgpuDockStatus
|
||||
from iqpilot.system.hardware.usb import get_link_error_count, get_usb_state, set_usb_state, usb3_lane
|
||||
from iqpilot.system.loggerd.config import get_available_percent
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.hardware.power_monitoring import PowerMonitoring, VBATT_LOW_POWER_EXIT
|
||||
@@ -41,7 +47,8 @@ CAN_STARTUP_RECOVERY_MAX_ATTEMPTS = 2
|
||||
|
||||
ThermalBand = namedtuple("ThermalBand", ['min_temp', 'max_temp'])
|
||||
HardwareState = namedtuple("HardwareState", ['network_type', 'network_info', 'network_strength', 'network_stats',
|
||||
'network_metered', 'modem_temps'])
|
||||
'network_metered', 'modem_temps', 'usb_state', 'usb_link_errors',
|
||||
'usb3_lane'])
|
||||
|
||||
# List of thermal bands. We will stay within this region as long as we are within the bounds.
|
||||
# When exiting the bounds, we'll jump to the lower or higher band. Bands are ordered in the dict.
|
||||
@@ -161,6 +168,56 @@ class _CarParamsCache:
|
||||
|
||||
|
||||
|
||||
class EgpuDockFlasher:
|
||||
"""Flash the dock's firmware offroad when it does not match what we ship.
|
||||
|
||||
Same policy as stock: the model runtime ignores a dock until its product
|
||||
string matches, so a mismatched dock is unusable until this runs. Bounded
|
||||
attempts, offroad only, one flash in flight at a time.
|
||||
"""
|
||||
MAX_ATTEMPTS = 3
|
||||
RETRY_INTERVAL = 20.
|
||||
|
||||
def __init__(self):
|
||||
self.thread: threading.Thread | None = None
|
||||
self.attempts = 0
|
||||
self.last_attempt = 0.
|
||||
self.flashed = False
|
||||
self.mismatch = False
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return (self.mismatch and self.attempts >= self.MAX_ATTEMPTS
|
||||
and self.thread is not None and not self.thread.is_alive() and not self.flashed)
|
||||
|
||||
def flash(self) -> None:
|
||||
ret = subprocess.run(["sudo", sys.executable,
|
||||
os.path.join(BASEDIR, "iqpilot/system/hardware/egpu_dock/flash.py")],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
|
||||
cloudlog.event("egpu dock flash done", returncode=ret.returncode, output=ret.stdout[-1000:],
|
||||
error=ret.returncode != 0)
|
||||
self.flashed = ret.returncode == 0
|
||||
|
||||
def update(self, offroad: bool, usb_state: list[dict]) -> None:
|
||||
self.mismatch = dock_needs_flash(usb_state)
|
||||
if not self.mismatch:
|
||||
self.flashed = False
|
||||
return
|
||||
|
||||
if not offroad or self.flashed or self.attempts >= self.MAX_ATTEMPTS:
|
||||
return
|
||||
if self.thread is not None and self.thread.is_alive():
|
||||
return
|
||||
if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL:
|
||||
return
|
||||
|
||||
self.attempts += 1
|
||||
self.last_attempt = time.monotonic()
|
||||
cloudlog.warning(f"egpu dock firmware out of date, flashing (attempt {self.attempts})")
|
||||
self.thread = threading.Thread(target=self.flash, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
|
||||
def set_offroad_alert_if_changed(offroad_alert: str, show_alert: bool, extra_text: str | None=None):
|
||||
if prev_offroad_states.get(offroad_alert, None) == (show_alert, extra_text):
|
||||
return
|
||||
@@ -254,6 +311,9 @@ def hw_state_thread(end_event, hw_queue):
|
||||
network_stats={'wwanTx': tx, 'wwanRx': rx},
|
||||
network_metered=HARDWARE.get_network_metered(network_type),
|
||||
modem_temps=modem_temps,
|
||||
usb_state=get_usb_state(),
|
||||
usb_link_errors=get_link_error_count(),
|
||||
usb3_lane=usb3_lane(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -280,7 +340,7 @@ def hw_state_thread(end_event, hw_queue):
|
||||
|
||||
def hardware_thread(end_event, hw_queue) -> None:
|
||||
pm = messaging.PubMaster(['deviceState', 'iqPerfTrace'])
|
||||
sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "carState"], poll="pandaStates")
|
||||
sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "carState", "egpuDockState"], poll="pandaStates")
|
||||
perf = PerfTraceEmitter("hardwared", pubmaster=pm)
|
||||
|
||||
count = 0
|
||||
@@ -306,6 +366,9 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
network_strength=NetworkStrength.unknown,
|
||||
network_stats={'wwanTx': -1, 'wwanRx': -1},
|
||||
modem_temps=[],
|
||||
usb_state=[],
|
||||
usb_link_errors=0,
|
||||
usb3_lane="unknown",
|
||||
)
|
||||
|
||||
all_temp_filter = FirstOrderFilter(0., TEMP_TAU, DT_HW, initialized=False)
|
||||
@@ -332,6 +395,8 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
thermal_config = HARDWARE.get_thermal_config()
|
||||
|
||||
fan_controller = FanController()
|
||||
egpu_dock_flasher = EgpuDockFlasher()
|
||||
egpu_dock_status = EgpuDockStatus()
|
||||
|
||||
while not end_event.is_set():
|
||||
sm.update(PANDA_STATES_TIMEOUT)
|
||||
@@ -427,6 +492,14 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
msg.deviceState.networkInfo = last_hw_state.network_info
|
||||
|
||||
msg.deviceState.modemTempC = last_hw_state.modem_temps
|
||||
set_usb_state(msg.deviceState, last_hw_state.usb_state, last_hw_state.usb_link_errors,
|
||||
last_hw_state.usb3_lane)
|
||||
egpu_dock_flasher.update(started_ts is None, last_hw_state.usb_state)
|
||||
egpu_valid = sm.alive["egpuDockState"] and sm.valid["egpuDockState"]
|
||||
egpu_dock_status.update(started_ts is None, last_hw_state.usb_state, egpu_dock_flasher.failed,
|
||||
params.get_bool("UsbGpuLoading"), params.get("UsbGpuActive"),
|
||||
params.get_bool("UsbGpuCompiled"),
|
||||
sm["egpuDockState"] if egpu_valid else None, set_offroad_alert_if_changed)
|
||||
|
||||
msg.deviceState.screenBrightnessPercent = HARDWARE.get_screen_brightness()
|
||||
|
||||
|
||||
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
|
||||
@@ -1,57 +1,41 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from iqpilot.system.hardware.fan_controller import FanController
|
||||
|
||||
ALL_CONTROLLERS = [FanController]
|
||||
|
||||
def patched_controller(mocker, controller_class):
|
||||
mocker.patch("os.system", new=mocker.Mock())
|
||||
return controller_class()
|
||||
|
||||
class TestFanController:
|
||||
def wind_up(self, controller, ignition=True):
|
||||
for _ in range(1000):
|
||||
controller.update(100, ignition)
|
||||
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 wind_down(self, controller, ignition=False):
|
||||
for _ in range(1000):
|
||||
controller.update(10, ignition)
|
||||
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
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_hot_onroad(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller)
|
||||
assert controller.update(100, True) >= 70
|
||||
def test_hot_onroad(self):
|
||||
assert FanController().update(100, True) >= 70
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_offroad_limits(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller)
|
||||
assert controller.update(100, False) <= 30
|
||||
def test_offroad_capped(self):
|
||||
c = FanController()
|
||||
for t in (60, 75, 85, 100):
|
||||
assert c.update(t, False) <= 30
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_no_fan_wear(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller)
|
||||
assert controller.update(10, False) == 0
|
||||
def test_no_fan_wear(self):
|
||||
assert FanController().update(10, False) == 0
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_limited(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_up(controller, True)
|
||||
assert controller.update(100, True) == 100
|
||||
def test_max_cool(self):
|
||||
c = FanController()
|
||||
assert c.update(80, True, True) == 100
|
||||
assert c.update(80, False, True) == 100
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_max_cool(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller)
|
||||
assert controller.update(80, True, True) == 100
|
||||
assert controller.update(80, False, True) == 100
|
||||
|
||||
@pytest.mark.parametrize("controller_class", ALL_CONTROLLERS)
|
||||
def test_windup_speed(self, mocker, controller_class):
|
||||
controller = patched_controller(mocker, controller_class)
|
||||
self.wind_down(controller, True)
|
||||
for _ in range(10):
|
||||
controller.update(90, True)
|
||||
assert controller.update(90, True) >= 60
|
||||
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
|
||||
|
||||
267
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
267
iqpilot/system/hardware/tests/test_usb_state.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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")
|
||||
@@ -78,32 +78,38 @@ unbind() {
|
||||
fi
|
||||
}
|
||||
|
||||
set_attr() {
|
||||
[ "$(cat "$1" 2>/dev/null)" = "$2" ] && return 0
|
||||
echo "$2" | sudo tee "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_base() {
|
||||
if ! mountpoint -q /config; then
|
||||
sudo mount -t configfs none /config
|
||||
fi
|
||||
sudo mkdir -p "$GADGET/strings/0x409" "$GADGET/configs/c.1/strings/0x409"
|
||||
cd "$GADGET"
|
||||
[ -s idVendor ] || echo 0x04D8 | sudo tee idVendor >/dev/null
|
||||
[ -s idProduct ] || echo 0x1235 | sudo tee idProduct >/dev/null
|
||||
[ -s strings/0x409/serialnumber ] || echo "$(cat /proc/cmdline | sed -e 's/^.*androidboot.serialno=//' -e 's/ .*$//')" | sudo tee strings/0x409/serialnumber >/dev/null
|
||||
[ -s strings/0x409/manufacturer ] || echo "comma.ai" | sudo tee strings/0x409/manufacturer >/dev/null
|
||||
[ -s strings/0x409/product ] || echo "IQ.Pilot" | sudo tee strings/0x409/product >/dev/null
|
||||
[ -s configs/c.1/MaxPower ] || echo 250 | sudo tee configs/c.1/MaxPower >/dev/null
|
||||
[ -s configs/c.1/strings/0x409/configuration ] || echo "IQ.Pilot" | sudo tee configs/c.1/strings/0x409/configuration >/dev/null
|
||||
# `[ -s ]` never guards a configfs attribute: an unset idVendor still reads back
|
||||
# as "0x0000", so those writes were all skipped and the gadget stayed nameless
|
||||
set_attr idVendor 0x04D8
|
||||
set_attr idProduct 0x1235
|
||||
set_attr strings/0x409/serialnumber "$(sed -e 's/^.*androidboot.serialno=//' -e 's/ .*$//' /proc/cmdline)"
|
||||
set_attr strings/0x409/manufacturer "comma.ai"
|
||||
set_attr strings/0x409/product "IQ.Pilot"
|
||||
set_attr configs/c.1/MaxPower 250
|
||||
set_attr configs/c.1/strings/0x409/configuration "IQ.Pilot"
|
||||
}
|
||||
|
||||
add_adb() {
|
||||
# same rationale as add_mass_storage: start from a clean slate to avoid stale busy attributes
|
||||
remove_adb
|
||||
cd "$GADGET"
|
||||
sudo mkdir -p functions/ncm.0 functions/ffs.adb
|
||||
sudo mkdir -p functions/ffs.adb
|
||||
sudo mkdir -p /dev/usb-ffs/adb
|
||||
if ! mountpoint -q /dev/usb-ffs/adb; then
|
||||
sudo mount -t functionfs adb /dev/usb-ffs/adb
|
||||
fi
|
||||
sudo rm -f configs/c.1/ncm.0 configs/c.1/ffs.adb
|
||||
sudo ln -s functions/ncm.0 configs/c.1/
|
||||
sudo rm -f configs/c.1/ffs.adb
|
||||
sudo ln -s functions/ffs.adb configs/c.1/
|
||||
setprop service.adb.tcp.port -1 2>/dev/null || true
|
||||
sudo systemctl start adbd
|
||||
@@ -115,9 +121,42 @@ remove_adb() {
|
||||
sudo systemctl stop adbd || true
|
||||
if [ -d "$GADGET" ]; then
|
||||
cd "$GADGET"
|
||||
sudo rm -f configs/c.1/ncm.0 configs/c.1/ffs.adb
|
||||
sudo rm -f configs/c.1/ffs.adb
|
||||
sudo umount /dev/usb-ffs/adb 2>/dev/null || true
|
||||
sudo rmdir functions/ncm.0 functions/ffs.adb 2>/dev/null || true
|
||||
sudo rmdir functions/ffs.adb 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ncm carries the usb0 ethernet link. ADB needs it, but so does the Mac-backed
|
||||
# model worker with ADB off, so it is enabled independently of either.
|
||||
# the kernel randomises the ncm MACs every boot, so macOS sees a new adapter each
|
||||
# time and orphans the network service holding the link's static address
|
||||
ncm_id() {
|
||||
local id
|
||||
id=$(tr -dc '0-9a-f' < /data/params/d/DongleId 2>/dev/null | tail -c 6)
|
||||
[ ${#id} -eq 6 ] || id="000001"
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
add_ncm() {
|
||||
remove_ncm
|
||||
cd "$GADGET"
|
||||
sudo mkdir -p functions/ncm.0
|
||||
local id
|
||||
id=$(ncm_id)
|
||||
# best effort: some kernels create the ncm netdev lazily and fail these writes
|
||||
# with ENODEV, and a pinned MAC is never worth losing the whole gadget over
|
||||
echo "02:49:51:${id:0:2}:${id:2:2}:${id:4:2}" | sudo tee functions/ncm.0/host_addr >/dev/null 2>&1 || true
|
||||
echo "06:49:51:${id:0:2}:${id:2:2}:${id:4:2}" | sudo tee functions/ncm.0/dev_addr >/dev/null 2>&1 || true
|
||||
sudo rm -f configs/c.1/ncm.0
|
||||
sudo ln -s functions/ncm.0 configs/c.1/
|
||||
}
|
||||
|
||||
remove_ncm() {
|
||||
if [ -d "$GADGET" ]; then
|
||||
cd "$GADGET"
|
||||
sudo rm -f configs/c.1/ncm.0
|
||||
sudo rmdir functions/ncm.0 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -162,10 +201,18 @@ USB_STORAGE_ENABLE=0
|
||||
read_bool_param "/data/params/d/UsbStorageEnabled" && USB_STORAGE_ENABLE=1
|
||||
ADB_ENABLE=0
|
||||
read_bool_param "/data/params/d/AdbEnabled" && ADB_ENABLE=1
|
||||
EMAC_ENABLE=0
|
||||
read_bool_param "/data/params/d/IQEmacEnabled" && EMAC_ENABLE=1
|
||||
|
||||
unbind
|
||||
ensure_base
|
||||
|
||||
if [ "$ADB_ENABLE" == "1" ] || [ "$EMAC_ENABLE" == "1" ]; then
|
||||
add_ncm
|
||||
else
|
||||
remove_ncm
|
||||
fi
|
||||
|
||||
if [ "$ADB_ENABLE" == "1" ]; then
|
||||
add_adb
|
||||
else
|
||||
|
||||
@@ -15,3 +15,57 @@ def apply_usb_storage_state(state: bool):
|
||||
subprocess.Popen(args)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
NCM_TRIED_MARKER = "/tmp/.iqemac_ncm_provisioned"
|
||||
MAX_NCM_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _ncm_attempts() -> int:
|
||||
try:
|
||||
with open(NCM_TRIED_MARKER) as f:
|
||||
return int(f.read().strip() or 0)
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_ncm_gadget() -> bool:
|
||||
# configfs is RAM backed, so the gadget must be rebuilt every boot; binding it
|
||||
# enumerates on the host, which must not happen mid-drive
|
||||
if os.path.isdir("/sys/class/net/usb0"):
|
||||
return True
|
||||
params = Params()
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected
|
||||
if not (params.get_bool("IQEmacEnabled") or egpu_selected(params)):
|
||||
return False
|
||||
attempts = _ncm_attempts()
|
||||
if attempts >= MAX_NCM_ATTEMPTS:
|
||||
return False
|
||||
try:
|
||||
# stamped before the run: the gadget build can wedge configfs in
|
||||
# uninterruptible D state, and retrying that forever helps nobody
|
||||
with open(NCM_TRIED_MARKER, "w") as f:
|
||||
f.write(str(attempts + 1))
|
||||
subprocess.run(["sudo", "-n", SCRIPT_PATH], timeout=120, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return os.path.isdir("/sys/class/net/usb0")
|
||||
|
||||
|
||||
INPUT_SUSPEND = "/sys/class/power_supply/battery/input_suspend"
|
||||
|
||||
|
||||
def suspend_usb_input(suspend: bool = True) -> bool:
|
||||
# a host on the data port makes the PMIC sink USB-PD while OBD-C feeds the same
|
||||
# rail; the SOM browns out. This closes the charge path only, data is untouched
|
||||
if not os.path.exists(INPUT_SUSPEND):
|
||||
return False
|
||||
try:
|
||||
with open(INPUT_SUSPEND) as f:
|
||||
if f.read().strip() == ("1" if suspend else "0"):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
rc = subprocess.run(["sudo", "-n", "sh", "-c", f"echo {int(suspend)} > {INPUT_SUSPEND}"],
|
||||
check=False, capture_output=True)
|
||||
return rc.returncode == 0
|
||||
|
||||
184
iqpilot/system/hardware/usb.py
Normal file
184
iqpilot/system/hardware/usb.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
USB bus snapshot for deviceState: every enumerated device with its negotiated
|
||||
speed and its controller's link-error count. Landing this in every rlog makes
|
||||
cable/hub/link regressions diagnosable from a recorded route instead of only
|
||||
live.
|
||||
|
||||
Link errors come from `portli` on the ssusb controller (IQ.OS 4.9.1+); on older
|
||||
builds the file is absent and the counts read 0.
|
||||
|
||||
The USB eGPU dock is identified by VID/PID only. comma's internal codename for
|
||||
it is deliberately not used here: IQ.Pilot runs these models on several
|
||||
backends (eGPU dock, eMac), so the naming stays about the role, not the vendor.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# comma's USB eGPU dock, both shipped USB IDs. The ROM ids are the same board
|
||||
# sitting in its bootloader (ASMedia) before vendor firmware is flashed — it
|
||||
# enumerates but cannot serve a GPU in that state.
|
||||
EGPU_DOCK_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
|
||||
EGPU_DOCK_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
|
||||
# must equal image_product() of the bundled firmware; test_egpu_dock_flash pins them together
|
||||
EGPU_DOCK_FW_PRODUCT = "custom ed4e39b7-CLEAN"
|
||||
|
||||
|
||||
def is_egpu_usb_device(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool:
|
||||
ids = EGPU_DOCK_USB_IDS + EGPU_DOCK_ROM_USB_IDS if include_bootloader else EGPU_DOCK_USB_IDS
|
||||
return (vendor_id, product_id) in ids
|
||||
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
|
||||
UDC_PATH = Path("/sys/class/udc")
|
||||
TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation")
|
||||
USB3_LANES = {1: "a", 2: "b"} # 0 = unattached
|
||||
SOC_PLATFORM_PATH = Path("/sys/devices/platform/soc")
|
||||
CONTROLLER_SUFFIX = ".ssusb"
|
||||
LINK_ERRORS_FILE = "portli"
|
||||
|
||||
|
||||
def read(path: Path) -> str | None:
|
||||
# a controller in peripheral mode fails portli's show(); that surfaces as TypeError, not OSError
|
||||
try:
|
||||
return path.read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_int(path: Path, base: int = 10) -> int:
|
||||
try:
|
||||
return int(path.read_text(), base)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def read_hex_counter(path: Path) -> int:
|
||||
"""sysfs counter printed as '0x0000002a' (portli), tolerating a bare hex value."""
|
||||
raw = read(path)
|
||||
if raw is None:
|
||||
return 0
|
||||
try:
|
||||
return int(raw, 0) if raw.lower().startswith("0x") else int(raw, 16)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_usb_topology(root: Path = USB_DEVICES_PATH) -> set[str]:
|
||||
"""Names of everything on the bus; a cheap way to detect hotplug without
|
||||
re-reading every attribute."""
|
||||
try:
|
||||
return {p.name for p in root.iterdir()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def usb_devices(root: Path = USB_DEVICES_PATH) -> list[Path]:
|
||||
try:
|
||||
return sorted((d for d in root.glob("*") if (d / "idVendor").exists()), key=lambda p: p.name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def controller(device: Path) -> Path | None:
|
||||
"""The SuperSpeed controller a device hangs off (…/a800000.ssusb)."""
|
||||
try:
|
||||
return next((p for p in device.resolve().parents if p.name.endswith(CONTROLLER_SUFFIX)), None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def usb_controllers(soc: Path = SOC_PLATFORM_PATH) -> list[Path]:
|
||||
try:
|
||||
return sorted(soc.glob(f"*{CONTROLLER_SUFFIX}"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def link_controller(udc_root: Path = UDC_PATH) -> str:
|
||||
"""Name of the Type-C port's controller, derived from the UDC rather than
|
||||
hardcoded: the gadget exposes `<addr>.dwc3`, whose address prefix is the
|
||||
`<addr>.ssusb` controller behind the same connector. comma pins the 3X value
|
||||
directly, which would be wrong on any other board."""
|
||||
try:
|
||||
udc = next(iter(sorted(p.name for p in udc_root.iterdir())), "")
|
||||
except Exception:
|
||||
return ""
|
||||
return f"{udc.split('.')[0]}{CONTROLLER_SUFFIX}" if udc else ""
|
||||
|
||||
|
||||
def usb3_lane(orientation: int | None = None) -> str:
|
||||
"""Which SuperSpeed lane the Type-C connector landed on. Unattached reads 0,
|
||||
which is 'unknown' rather than a lane."""
|
||||
if orientation is None:
|
||||
orientation = read_int(TYPEC_CC_ORIENTATION_PATH)
|
||||
return USB3_LANES.get(orientation, "unknown")
|
||||
|
||||
|
||||
def link_errors(ctrl: Path | None) -> int:
|
||||
return read_hex_counter(ctrl / LINK_ERRORS_FILE) if ctrl is not None else 0
|
||||
|
||||
|
||||
def get_link_error_count(soc: Path = SOC_PLATFORM_PATH) -> int:
|
||||
"""Cumulative SS port link errors, read off the controller rather than a
|
||||
device: in peripheral mode (eMac gadget link) the peer never enumerates on
|
||||
our side, so there is no device row to carry the count."""
|
||||
return sum(link_errors(c) for c in usb_controllers(soc))
|
||||
|
||||
|
||||
def egpu_dock_present(root: Path = USB_DEVICES_PATH) -> bool:
|
||||
"""A dock in ROM/bootloader state is deliberately NOT counted as present: it
|
||||
enumerates but cannot serve a GPU until vendor firmware is flashed."""
|
||||
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
|
||||
for d in usb_devices(root))
|
||||
|
||||
|
||||
def egpu_dock_ready(root: Path = USB_DEVICES_PATH) -> bool:
|
||||
"""Present AND running the exact firmware we ship. A dock on any other
|
||||
firmware enumerates fine but has not been validated with this stack, so the
|
||||
runtime refuses it; the flasher still sees it via egpu_dock_present."""
|
||||
return any((read_int(d / "idVendor", 16), read_int(d / "idProduct", 16)) in EGPU_DOCK_USB_IDS
|
||||
and (read(d / "product") or "").strip() == EGPU_DOCK_FW_PRODUCT
|
||||
for d in usb_devices(root))
|
||||
|
||||
|
||||
def get_usb_state(root: Path = USB_DEVICES_PATH, udc_root: Path = UDC_PATH) -> list[dict]:
|
||||
devices = []
|
||||
lane, link_ctrl = usb3_lane(), link_controller(udc_root)
|
||||
for device in usb_devices(root):
|
||||
ctrl = controller(device)
|
||||
devices.append({
|
||||
"usb3Lane": lane if ctrl is not None and ctrl.name == link_ctrl else "unknown",
|
||||
"busnum": read_int(device / "busnum"),
|
||||
"devnum": read_int(device / "devnum"),
|
||||
"vendorId": read_int(device / "idVendor", 16),
|
||||
"productId": read_int(device / "idProduct", 16),
|
||||
"speedMbps": read_int(device / "speed"),
|
||||
"manufacturer": read(device / "manufacturer") or "",
|
||||
"product": read(device / "product") or "",
|
||||
# 16-bit field upstream, so mask rather than let a wrapped counter overflow it
|
||||
"linkErrorCount": link_errors(ctrl) & 0xFFFF,
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def set_usb_state(device_state, devices: list[dict], link_error_count: int = 0,
|
||||
lane: str | None = None) -> None:
|
||||
entries = device_state.usbState.init('devices', len(devices))
|
||||
|
||||
dock_present = False
|
||||
for entry, device in zip(entries, devices, strict=True):
|
||||
entry.busnum = device["busnum"]
|
||||
entry.devnum = device["devnum"]
|
||||
entry.vendorId = device["vendorId"]
|
||||
entry.productId = device["productId"]
|
||||
entry.speedMbps = device["speedMbps"]
|
||||
entry.manufacturer = device["manufacturer"]
|
||||
entry.product = device["product"]
|
||||
entry.linkErrorCount = device.get("linkErrorCount", 0) & 0xFFFF
|
||||
entry.usb3Lane = device.get("usb3Lane", "unknown")
|
||||
|
||||
if (entry.vendorId, entry.productId) in EGPU_DOCK_USB_IDS:
|
||||
dock_present = True
|
||||
|
||||
device_state.usbState.linkErrorCount = link_error_count
|
||||
device_state.usbState.usb3Lane = lane if lane is not None else usb3_lane()
|
||||
device_state.egpuDockPresent = dock_present
|
||||
@@ -53,6 +53,16 @@ def manager_init() -> None:
|
||||
except Exception:
|
||||
cloudlog.exception("recover_unclean_segments failed")
|
||||
|
||||
try:
|
||||
from iqpilot.system.hardware import TICI
|
||||
if TICI:
|
||||
from iqpilot.system.hardware.tici.usb_storage import ensure_ncm_gadget, suspend_usb_input
|
||||
ensure_ncm_gadget()
|
||||
if Params().get_bool("IQEmacEnabled") or Params().get_bool("IQEgpuEnabled"):
|
||||
suspend_usb_input(True)
|
||||
except Exception:
|
||||
cloudlog.exception("emac usb setup failed")
|
||||
|
||||
build_metadata = get_build_metadata()
|
||||
|
||||
params = Params()
|
||||
|
||||
@@ -8,6 +8,7 @@ from iqpilot.system.hardware import HARDWARE, PC, TICI
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
from iqpilot.system.manager.process import PythonProcess, NativeProcess, BundleProcess
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.egpu_helpers import egpu_selected, resolve_backend, usbgpu_present
|
||||
from iqpilot.selfdrive.iqmodeld.models.helpers import get_active_model_runner
|
||||
from iqpilot.konn3kt.service_health import hephaestus_ready
|
||||
|
||||
@@ -124,6 +125,22 @@ def is_tinygrad_model(started, params, CP: car.CarParams) -> bool:
|
||||
"""Check if the active model runner is tinygrad."""
|
||||
return bool(get_active_model_runner(params, not started) == custom.IQModelManager.Runner.tinygrad)
|
||||
|
||||
def _egpu_present(params) -> bool:
|
||||
if params.get_bool("IQEgpuDisabled"):
|
||||
return False
|
||||
return usbgpu_present()
|
||||
|
||||
|
||||
def emac_enabled(started, params, CP: car.CarParams) -> bool:
|
||||
return resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), _egpu_present(params)) == "emac"
|
||||
|
||||
def egpu_enabled(started, params, CP: car.CarParams) -> bool:
|
||||
return (resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), _egpu_present(params)) == "egpu"
|
||||
and _egpu_present(params))
|
||||
|
||||
def big_model_enabled(started, params, CP: car.CarParams) -> bool:
|
||||
return params.get_bool("IQEmacEnabled") or egpu_selected(params)
|
||||
|
||||
def hephaestus_ready_shim(started, params, CP: car.CarParams) -> bool:
|
||||
return hephaestus_ready(params)
|
||||
|
||||
@@ -196,6 +213,15 @@ procs += [
|
||||
# Models
|
||||
BundleProcess("models_manager", "iqpilot_model_selector_private", "iqpilot_private.models.manager", and_(only_offroad, not_low_power)),
|
||||
NativeProcess("iqmodeld", "iqpilot/selfdrive/iqmodeld", ["./iqmodeld"], and_(only_onroad, is_tinygrad_model), restart_if_crash=True),
|
||||
# big-model backends: iqmodeld self-demotes to the small channel worker when
|
||||
# either backend is enabled; the selector publishes, and exactly one big
|
||||
# worker (Mac or eGPU, eMac wins) feeds the BIG channel
|
||||
PythonProcess("modeld_selector", "iqpilot.selfdrive.iqmodeld.modeld_selector",
|
||||
and_(only_onroad, and_(is_tinygrad_model, big_model_enabled)), restart_if_crash=True),
|
||||
BundleProcess("maciqmodeld", "iqpilot_emac_private", "iqpilot_private.emac.maciqmodeld",
|
||||
and_(only_onroad, and_(is_tinygrad_model, emac_enabled)), restart_if_crash=True),
|
||||
PythonProcess("iqegpumodeld", "iqpilot.selfdrive.iqmodeld.iqegpumodeld",
|
||||
and_(only_onroad, and_(is_tinygrad_model, egpu_enabled)), restart_if_crash=True),
|
||||
|
||||
BundleProcess("backup_manager_k3", "iqpilot_hephaestusd_private", "iqpilot_private.konn3kt.backups.backup_orchestrator",
|
||||
and_(only_offroad, hephaestus_ready_shim, not_low_power)),
|
||||
|
||||
@@ -21,6 +21,10 @@ CACHE_SIZE = 10 * 1024 * 1024 * 1024 # total cache size in GB
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
USER_AGENT = os.getenv("IQPILOT_HTTP_USER_AGENT", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
@@ -113,6 +117,10 @@ class URLFile:
|
||||
pass
|
||||
|
||||
def _request(self, method: str, url: str, headers: dict[str, str] | None = None) -> BaseHTTPResponse:
|
||||
# the data host is behind cloudflare, which answers a default urllib3 agent
|
||||
# with a 1010 block. It reads as 403 Forbidden on a correctly signed url, so
|
||||
# it looks like an auth problem and is not one.
|
||||
headers = {**(headers or {}), "User-Agent": USER_AGENT}
|
||||
try:
|
||||
return URLFile.pool_manager().request(method, url, timeout=self._timeout, headers=headers)
|
||||
except MaxRetryError as e:
|
||||
|
||||
@@ -70,6 +70,9 @@ void CameraServer::cameraThread(Camera &cam) {
|
||||
.timestamp_eof = eidx.getTimestampEof(),
|
||||
};
|
||||
vipc_server_->send(yuv, &extra);
|
||||
if (++sent_count_ % 100 == 1) {
|
||||
rInfo("camera[%d] vipc send #%lu frame_id=%u seg_frame=%d", cam.type, sent_count_, frame_id, segment_id);
|
||||
}
|
||||
} else {
|
||||
rError("camera[%d] failed to get frame: %lu", cam.type, segment_id);
|
||||
}
|
||||
|
||||
@@ -38,5 +38,6 @@ protected:
|
||||
{.type = WideRoadCam, .stream_type = VISION_STREAM_WIDE_ROAD},
|
||||
};
|
||||
std::atomic<int> publishing_ = 0;
|
||||
uint64_t sent_count_ = 0;
|
||||
std::unique_ptr<VisionIpcServer> vipc_server_;
|
||||
};
|
||||
|
||||
@@ -107,11 +107,21 @@ bool FrameReader::loadFromFile(CameraType type, const std::string &file, bool no
|
||||
packets_info.reserve(60 * 20); // 20fps, one minute
|
||||
while (!(abort && *abort) && av_read_frame(input_ctx, &pkt) == 0) {
|
||||
if (pkt.stream_index == video_stream_idx_) {
|
||||
packets_info.emplace_back(PacketInfo{.flags = pkt.flags, .pos = pkt.pos});
|
||||
packets_info.emplace_back(PacketInfo{.flags = pkt.flags, .pos = pkt.pos, .ts = pkt.dts});
|
||||
}
|
||||
av_packet_unref(&pkt);
|
||||
}
|
||||
avio_seek(input_ctx->pb, 0, SEEK_SET);
|
||||
// IQ.Pilot camera files are (fragmented) MP4: rewinding the raw pb leaves the
|
||||
// mov demuxer's sample cursor at EOF, so rewind through the demuxer instead.
|
||||
// comma's raw HEVC bitstreams have no index; only the pb rewind works there.
|
||||
if (!packets_info.empty() &&
|
||||
avformat_seek_file(input_ctx, video_stream_idx_, INT64_MIN,
|
||||
packets_info.front().ts, packets_info.front().ts, 0) < 0) {
|
||||
avformat_flush(input_ctx);
|
||||
avio_seek(input_ctx->pb, 0, SEEK_SET);
|
||||
}
|
||||
rInfo("frame index built: %zu packets, fmt=%s", packets_info.size(),
|
||||
input_ctx->iformat ? input_ctx->iformat->name : "?");
|
||||
return !packets_info.empty();
|
||||
}
|
||||
|
||||
@@ -147,6 +157,10 @@ bool FFmpegVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) {
|
||||
}
|
||||
width = (decoder_ctx->width + 3) & ~3;
|
||||
height = decoder_ctx->height;
|
||||
// frame-threaded software decode: single-threaded can't hold 2x1928x1208@20
|
||||
// on this SoC. The added output delay is absorbed by the EAGAIN-aware loop.
|
||||
decoder_ctx->thread_count = 3;
|
||||
decoder_ctx->thread_type = FF_THREAD_FRAME;
|
||||
|
||||
if (hw_decoder && !initHardwareDecoder(HW_DEVICE_TYPE)) {
|
||||
rWarning("No device with hardware decoder found. fallback to CPU decoding.");
|
||||
@@ -188,6 +202,14 @@ bool FFmpegVideoDecoder::initHardwareDecoder(AVHWDeviceType hw_device_type) {
|
||||
bool FFmpegVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
int current_idx = idx;
|
||||
if (idx != reader->prev_idx + 1) {
|
||||
if (idx > reader->prev_idx && idx - reader->prev_idx <= 300) {
|
||||
// forward catch-up: the decoder is already positioned at prev_idx+1, and
|
||||
// sequential decode is cheaper and (for raw H.264) more reliable than a
|
||||
// byte seek plus keyframe re-decode
|
||||
current_idx = reader->prev_idx + 1;
|
||||
reader->prev_idx = idx;
|
||||
goto read_packets;
|
||||
}
|
||||
// seeking to the nearest key frame
|
||||
for (int i = idx; i >= 0; --i) {
|
||||
if (reader->packets_info[i].flags & AV_PKT_FLAG_KEY) {
|
||||
@@ -198,6 +220,12 @@ bool FFmpegVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
|
||||
auto pos = reader->packets_info[current_idx].pos;
|
||||
int ret = avformat_seek_file(reader->input_ctx, 0, pos, pos, pos, AVSEEK_FLAG_BYTE);
|
||||
if (ret < 0) {
|
||||
// mp4 containers reject byte seeks; seek the keyframe by timestamp
|
||||
// through the mov index instead
|
||||
auto ts = reader->packets_info[current_idx].ts;
|
||||
ret = avformat_seek_file(reader->input_ctx, reader->video_stream_idx_, INT64_MIN, ts, ts, 0);
|
||||
}
|
||||
if (ret < 0) {
|
||||
rError("Failed to seek to byte position %lld: %d", pos, AVERROR(ret));
|
||||
return false;
|
||||
@@ -206,26 +234,42 @@ bool FFmpegVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
}
|
||||
reader->prev_idx = idx;
|
||||
|
||||
read_packets:
|
||||
// H.264 has decoder delay: the first packets may legitimately yield no frame
|
||||
// yet (EAGAIN), and one packet can release several buffered frames. comma's
|
||||
// zero-delay HEVC never exercised either case.
|
||||
AVPacket pkt;
|
||||
while (av_read_frame(reader->input_ctx, &pkt) >= 0) {
|
||||
// Skip non-video packets
|
||||
int rf_ret;
|
||||
while ((rf_ret = av_read_frame(reader->input_ctx, &pkt)) >= 0) {
|
||||
if (pkt.stream_index != reader->video_stream_idx_) {
|
||||
av_packet_unref(&pkt);
|
||||
continue;
|
||||
}
|
||||
|
||||
AVFrame *frame = decodeFrame(&pkt);
|
||||
int ret = avcodec_send_packet(decoder_ctx, &pkt);
|
||||
av_packet_unref(&pkt);
|
||||
if (!frame) {
|
||||
rError("Failed to decode frame at index %d", current_idx);
|
||||
if (ret < 0) {
|
||||
rError("Error sending a packet for decoding: %d", ret);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current_idx++ == idx) {
|
||||
return copyBuffer(frame, buf);
|
||||
while ((ret = avcodec_receive_frame(decoder_ctx, av_frame_)) == 0) {
|
||||
AVFrame *frame = av_frame_;
|
||||
if (av_frame_->format == hw_pix_fmt) {
|
||||
if (av_hwframe_transfer_data(hw_frame_, av_frame_, 0) < 0) {
|
||||
rError("error transferring frame data from GPU to CPU");
|
||||
return false;
|
||||
}
|
||||
frame = hw_frame_;
|
||||
}
|
||||
if (current_idx++ == idx) {
|
||||
return copyBuffer(frame, buf);
|
||||
}
|
||||
}
|
||||
if (ret != AVERROR(EAGAIN)) {
|
||||
rError("avcodec_receive_frame error: %d", ret);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
rError("Failed to find frame at index %d", idx);
|
||||
rError("Failed to find frame at index %d (read ret=%d)", idx, rf_ret);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -268,20 +312,46 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) {
|
||||
}
|
||||
|
||||
#ifndef __APPLE__
|
||||
QcomVideoDecoder::~QcomVideoDecoder() {
|
||||
if (bsf_) av_bsf_free(&bsf_);
|
||||
}
|
||||
|
||||
bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) {
|
||||
if (codecpar->codec_id != AV_CODEC_ID_HEVC) {
|
||||
rError("Hardware decoder only supports HEVC codec");
|
||||
// msm_vidc decodes both; IQ.Pilot recordings are H.264 while comma's are HEVC
|
||||
uint32_t v4l2_fmt;
|
||||
if (codecpar->codec_id == AV_CODEC_ID_HEVC) {
|
||||
v4l2_fmt = V4L2_PIX_FMT_HEVC;
|
||||
} else if (codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
v4l2_fmt = V4L2_PIX_FMT_H264;
|
||||
if (codecpar->extradata && codecpar->extradata_size > 0) {
|
||||
// mp4 carries AVCC (length-prefixed NALs, headers out-of-band); the V4L2
|
||||
// decoder wants an Annex-B bitstream with in-band SPS/PPS
|
||||
const AVBitStreamFilter *f = av_bsf_get_by_name("h264_mp4toannexb");
|
||||
if (!f || av_bsf_alloc(f, &bsf_) < 0 ||
|
||||
avcodec_parameters_copy(bsf_->par_in, codecpar) < 0 || av_bsf_init(bsf_) < 0) {
|
||||
rError("failed to set up h264_mp4toannexb filter");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rError("Hardware decoder only supports HEVC and H.264 codecs");
|
||||
return false;
|
||||
}
|
||||
width = codecpar->width;
|
||||
height = codecpar->height;
|
||||
msm_vidc.init(VIDEO_DEVICE, width, height, V4L2_PIX_FMT_HEVC);
|
||||
msm_vidc.init(VIDEO_DEVICE, width, height, v4l2_fmt);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
int from_idx = idx;
|
||||
if (idx != reader->prev_idx + 1) {
|
||||
if (idx > reader->prev_idx && idx - reader->prev_idx <= 300) {
|
||||
// forward catch-up: the decoder is already positioned at prev_idx+1, and
|
||||
// sequential decode is cheaper and (for raw H.264) more reliable than a
|
||||
// byte seek plus keyframe re-decode
|
||||
from_idx = reader->prev_idx + 1;
|
||||
} else {
|
||||
// seeking to the nearest key frame
|
||||
for (int i = idx; i >= 0; --i) {
|
||||
if (reader->packets_info[i].flags & AV_PKT_FLAG_KEY) {
|
||||
@@ -292,10 +362,17 @@ bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
|
||||
auto pos = reader->packets_info[from_idx].pos;
|
||||
int ret = avformat_seek_file(reader->input_ctx, 0, pos, pos, pos, AVSEEK_FLAG_BYTE);
|
||||
if (ret < 0) {
|
||||
// mp4 containers reject byte seeks; seek the keyframe by timestamp
|
||||
// through the mov index instead
|
||||
auto ts = reader->packets_info[from_idx].ts;
|
||||
ret = avformat_seek_file(reader->input_ctx, reader->video_stream_idx_, INT64_MIN, ts, ts, 0);
|
||||
}
|
||||
if (ret < 0) {
|
||||
rError("Failed to seek to byte position %lld: %d", pos, AVERROR(ret));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader->prev_idx = idx;
|
||||
bool result = false;
|
||||
@@ -303,6 +380,13 @@ bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
|
||||
msm_vidc.avctx = reader->input_ctx;
|
||||
for (int i = from_idx; i <= idx; ++i) {
|
||||
if (av_read_frame(reader->input_ctx, &pkt) == 0) {
|
||||
if (bsf_ != nullptr) {
|
||||
if (av_bsf_send_packet(bsf_, &pkt) < 0 || av_bsf_receive_packet(bsf_, &pkt) < 0) {
|
||||
rError("h264_mp4toannexb failed at index %d", i);
|
||||
av_packet_unref(&pkt);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
result = msm_vidc.decodeFrame(&pkt, buf) && (i == idx);
|
||||
av_packet_unref(&pkt);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ public:
|
||||
struct PacketInfo {
|
||||
int flags;
|
||||
int64_t pos;
|
||||
int64_t ts; // dts; byte pos is useless for seeking in mp4 containers
|
||||
};
|
||||
std::vector<PacketInfo> packets_info;
|
||||
};
|
||||
@@ -72,11 +73,12 @@ private:
|
||||
class QcomVideoDecoder : public VideoDecoder {
|
||||
public:
|
||||
QcomVideoDecoder() {};
|
||||
~QcomVideoDecoder() override {};
|
||||
~QcomVideoDecoder() override;
|
||||
bool open(AVCodecParameters *codecpar, bool hw_decoder) override;
|
||||
bool decode(FrameReader *reader, int idx, VisionBuf *buf) override;
|
||||
|
||||
private:
|
||||
MsmVidc msm_vidc = MsmVidc();
|
||||
AVBSFContext *bsf_ = nullptr; // AVCC (mp4) -> Annex-B for msm_vidc
|
||||
};
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <getopt.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
@@ -176,6 +177,15 @@ int main(int argc, char *argv[]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// REPLAY_HEADLESS: skip ncurses, which needs a real TTY and swallows all
|
||||
// replay log output into its UI — required when driven from a service
|
||||
if (getenv("REPLAY_HEADLESS") != nullptr) {
|
||||
replay.start(config.start_seconds);
|
||||
while (true) {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
ConsoleUI console_ui(&replay);
|
||||
replay.start(config.start_seconds);
|
||||
return console_ui.exec();
|
||||
|
||||
@@ -43,7 +43,7 @@ bool MsmVidc::init(const char* dev, size_t width, size_t height, uint64_t codec)
|
||||
}
|
||||
subscribeEvents();
|
||||
v4l2_buf_type out_type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;
|
||||
setPlaneFormat(out_type, V4L2_PIX_FMT_HEVC); // Also allocates the output buffer
|
||||
setPlaneFormat(out_type, codec); // Also allocates the output buffer
|
||||
setFPS(FPS);
|
||||
request_buffers(fd, out_type, OUTPUT_BUFFER_COUNT);
|
||||
util::safe_ioctl(fd, VIDIOC_STREAMON, &out_type, "VIDIOC_STREAMON OUTPUT failed");
|
||||
|
||||
@@ -1590,6 +1590,31 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
|
||||
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
||||
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
||||
|
||||
def _big_model_options() -> list[tuple[str, str]]:
|
||||
try:
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import big_models
|
||||
return big_models(ui_state.params)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _big_model_label(key: str) -> str:
|
||||
for name, display in _big_model_options():
|
||||
if name == key:
|
||||
return display
|
||||
return key
|
||||
|
||||
|
||||
def _refresh_big_catalog() -> None:
|
||||
def worker():
|
||||
try:
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import refresh_catalog
|
||||
refresh_catalog(ui_state.params)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
class ModelsLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -1599,6 +1624,7 @@ class ModelsLayout(Widget):
|
||||
self.download_status = None
|
||||
self.prev_download_status = None
|
||||
self.model_dialog = None
|
||||
self._big_model_dialog = None
|
||||
self.last_cache_calc_time = 0
|
||||
|
||||
self._initialize_items()
|
||||
@@ -1614,6 +1640,14 @@ class ModelsLayout(Widget):
|
||||
callback=self._handle_current_model_clicked
|
||||
)
|
||||
|
||||
self.big_model_item = button_item(
|
||||
lambda: tr("Big Model"),
|
||||
lambda: tr("CHANGE"),
|
||||
tr("Only works with external compute connected over USB."),
|
||||
self._handle_big_model_clicked,
|
||||
)
|
||||
self.big_model_item.action_item.set_value(self._big_model_value())
|
||||
|
||||
self.supercombo_label = progress_item(tr("Combined Model"))
|
||||
self.vision_label = progress_item(tr("Vision Weights"))
|
||||
self.policy_label = progress_item(tr("Policy Weights"))
|
||||
@@ -1630,7 +1664,7 @@ class ModelsLayout(Widget):
|
||||
self.redownload_item = button_item(lambda: tr("Redownload Current Model"), lambda: tr("REDOWNLOAD"), "", self._redownload_model)
|
||||
self.cancel_download_item = button_item(tr("Stop Download"), tr("Cancel"), "", self._cancel_model_request)
|
||||
|
||||
self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label,
|
||||
self.items = [self.current_model_item, self.big_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label,
|
||||
self.policy_label, self.redownload_item, self.refresh_item, self.clear_cache_item]
|
||||
|
||||
def _is_downloading(self):
|
||||
@@ -1880,7 +1914,40 @@ class ModelsLayout(Widget):
|
||||
get_folders_fn=self._get_folders, on_exit=self._on_model_selected)
|
||||
gui_app.set_modal_overlay(self.model_dialog, callback=self._on_model_selected)
|
||||
|
||||
@staticmethod
|
||||
def _big_model_value() -> str:
|
||||
if not ui_state.params.get_bool("IQEmacEnabled"):
|
||||
return tr("Off")
|
||||
key = ui_state.params.get("IQEmacModel")
|
||||
key = key.decode() if isinstance(key, bytes) else (key or "")
|
||||
return _big_model_label(key) if key in [n for n, _ in _big_model_options()] else tr("Off")
|
||||
|
||||
def _handle_big_model_clicked(self):
|
||||
options = _big_model_options()
|
||||
if len(options) <= 1:
|
||||
_refresh_big_catalog()
|
||||
keys = [n for n, _ in options]
|
||||
labels = [tr("Off")] + [d for _, d in options]
|
||||
self._big_model_dialog = MultiOptionDialog(tr("Big Model"), labels, self._big_model_value())
|
||||
|
||||
def handle_selection(result):
|
||||
if result == DialogResult.CONFIRM and self._big_model_dialog is not None and self._big_model_dialog.selection:
|
||||
selected = self._big_model_dialog.selection
|
||||
if selected == tr("Off"):
|
||||
ui_state.params.put_bool("IQEmacEnabled", False)
|
||||
else:
|
||||
for key in keys:
|
||||
if _big_model_label(key) == selected:
|
||||
ui_state.params.put("IQEmacModel", key)
|
||||
ui_state.params.put_bool("IQEmacEnabled", True)
|
||||
break
|
||||
self.big_model_item.action_item.set_value(self._big_model_value())
|
||||
self._big_model_dialog = None
|
||||
|
||||
gui_app.set_modal_overlay(self._big_model_dialog, callback=handle_selection)
|
||||
|
||||
def _on_refresh_models(self):
|
||||
_refresh_big_catalog()
|
||||
ui_state.params.put("ModelManager_LastSyncTime", 0)
|
||||
self._refreshing = True
|
||||
self._refresh_start = time.monotonic()
|
||||
|
||||
43
iqpilot/ui/mici/onroad/emac_source.py
Normal file
43
iqpilot/ui/mici/onroad/emac_source.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.ui.onroad.big_model_status import SourceState, draw_source_label, resolve_source
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
_POLL_S = 1.0
|
||||
_FONT_SIZE = 44
|
||||
_WHEEL_H = 50
|
||||
_MARGIN_R = 12
|
||||
_MARGIN_B = 14
|
||||
|
||||
|
||||
class EmacSourceIndicator:
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._last_poll = 0.0
|
||||
self._label = ""
|
||||
self._state = SourceState.HIDDEN
|
||||
|
||||
def update(self) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_poll < _POLL_S:
|
||||
return
|
||||
self._last_poll = now
|
||||
self._label, self._state = resolve_source(self._params, ui_state.engaged)
|
||||
|
||||
def render(self, rect: rl.Rectangle) -> None:
|
||||
if self._state == SourceState.HIDDEN:
|
||||
return
|
||||
size = measure_text_cached(self._font, self._label, _FONT_SIZE)
|
||||
x = rect.x + rect.width - _MARGIN_R - size.x
|
||||
y = rect.y + rect.height - _MARGIN_B - (_WHEEL_H + size.y) / 2
|
||||
draw_source_label(self._font, self._label, self._state, rl.Vector2(x, y), _FONT_SIZE)
|
||||
@@ -5,11 +5,12 @@ import pyray as rl
|
||||
|
||||
from iqpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
|
||||
from iqpilot.ui.onroad.hud_overlays import IQBlindSpotOverlay
|
||||
from iqpilot.ui.mici.onroad.emac_source import EmacSourceIndicator
|
||||
|
||||
class IQMiciHudRenderer(HudRenderer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._overlays = [IQBlindSpotOverlay()]
|
||||
self._overlays = [IQBlindSpotOverlay(), EmacSourceIndicator()]
|
||||
|
||||
def _update_state(self) -> None:
|
||||
super()._update_state()
|
||||
|
||||
71
iqpilot/ui/onroad/big_model_status.py
Normal file
71
iqpilot/ui/onroad/big_model_status.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import math
|
||||
from enum import IntEnum
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
class SourceState(IntEnum):
|
||||
HIDDEN = 0
|
||||
LOADING = 1
|
||||
ACTIVE = 2
|
||||
FAILED = 3
|
||||
CROSSED = 4
|
||||
|
||||
|
||||
_GREEN = rl.Color(46, 204, 113, 255)
|
||||
_ORANGE = rl.Color(255, 115, 0, 255)
|
||||
_WHITE = rl.Color(255, 255, 255, 255)
|
||||
|
||||
|
||||
def _emac_state(params: Params, engaged: bool) -> SourceState:
|
||||
if not params.get_bool("MacModelReachable"):
|
||||
return SourceState.HIDDEN
|
||||
if params.get_bool("MacModelActive"):
|
||||
return SourceState.ACTIVE
|
||||
if params.get_bool("MacModelFailed"):
|
||||
return SourceState.CROSSED if engaged else SourceState.FAILED
|
||||
return SourceState.LOADING
|
||||
|
||||
|
||||
def _egpu_state(params: Params, engaged: bool) -> SourceState:
|
||||
if not params.get_bool("UsbGpuPresent"):
|
||||
return SourceState.HIDDEN
|
||||
if params.get_bool("UsbGpuActive"):
|
||||
return SourceState.ACTIVE
|
||||
if params.get_bool("UsbGpuFailed"):
|
||||
return SourceState.CROSSED if engaged else SourceState.FAILED
|
||||
return SourceState.LOADING
|
||||
|
||||
|
||||
def resolve_source(params: Params, engaged: bool) -> tuple[str, SourceState]:
|
||||
if params.get_bool("IQEmacEnabled"):
|
||||
return "MAC", _emac_state(params, engaged)
|
||||
if params.get_bool("UsbGpuPresent") or params.get_bool("IQEgpuEnabled"):
|
||||
return "GPU", _egpu_state(params, engaged)
|
||||
return "", SourceState.HIDDEN
|
||||
|
||||
|
||||
def draw_source_label(font: rl.Font, label: str, state: SourceState,
|
||||
pos: rl.Vector2, font_size: int) -> None:
|
||||
if state == SourceState.HIDDEN or not label:
|
||||
return
|
||||
if state == SourceState.ACTIVE:
|
||||
color, opacity, strike = _GREEN, 1.0, False
|
||||
elif state == SourceState.FAILED:
|
||||
color, opacity, strike = _ORANGE, 1.0, False
|
||||
elif state == SourceState.CROSSED:
|
||||
color, opacity, strike = _WHITE, 0.65, True
|
||||
else:
|
||||
color, opacity, strike = _WHITE, 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)), False
|
||||
|
||||
col = rl.Color(color.r, color.g, color.b, int(255 * opacity))
|
||||
rl.draw_text_ex(font, label, pos, font_size, 0, col)
|
||||
if strike:
|
||||
size = measure_text_cached(font, label, font_size)
|
||||
y = int(pos.y + size.y / 2)
|
||||
rl.draw_line_ex(rl.Vector2(pos.x - 2, y), rl.Vector2(pos.x + size.x + 2, y), 4, col)
|
||||
44
iqpilot/ui/onroad/emac_status.py
Normal file
44
iqpilot/ui/onroad/emac_status.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.ui.onroad.big_model_status import SourceState, draw_source_label, resolve_source
|
||||
from iqpilot.selfdrive.ui import UI_BORDER_SIZE
|
||||
from iqpilot.selfdrive.ui.onroad.driver_state import BTN_SIZE
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
_POLL_S = 1.0
|
||||
_FONT_SIZE = 70
|
||||
|
||||
|
||||
class EmacStatusRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._last_poll = 0.0
|
||||
self._label = ""
|
||||
self._state = SourceState.HIDDEN
|
||||
|
||||
def update(self):
|
||||
now = time.monotonic()
|
||||
if now - self._last_poll < _POLL_S:
|
||||
return
|
||||
self._last_poll = now
|
||||
self._label, self._state = resolve_source(self._params, ui_state.engaged)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._state == SourceState.HIDDEN:
|
||||
return
|
||||
size = measure_text_cached(self._font, self._label, _FONT_SIZE)
|
||||
x = rect.x + UI_BORDER_SIZE + BTN_SIZE // 2 - size.x / 2
|
||||
y = rect.y + rect.height / 2 - size.y / 2
|
||||
draw_source_label(self._font, self._label, self._state, rl.Vector2(x, y), _FONT_SIZE)
|
||||
@@ -16,6 +16,7 @@ from iqpilot.ui.onroad.hud_overlays import (
|
||||
)
|
||||
from iqpilot.ui.onroad.nav_map_panel import NavMapPanel
|
||||
from iqpilot.ui.onroad.soft_warning import SoftWarningRenderer
|
||||
from iqpilot.ui.onroad.emac_status import EmacStatusRenderer
|
||||
|
||||
ENABLE_FLOATING_NAV_MAP_PANEL = False
|
||||
ENABLE_SPLIT_NAV_MAP_PANEL = True
|
||||
@@ -25,6 +26,7 @@ class IQHudRenderer(HudRenderer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.developer_ui = IQDevMetricsOverlay()
|
||||
self.emac_status = EmacStatusRenderer()
|
||||
self.nav_map_panel = NavMapPanel()
|
||||
self.road_name_renderer = RoadNameRenderer()
|
||||
self.rocket_fuel = IQAccelBar()
|
||||
@@ -38,6 +40,7 @@ class IQHudRenderer(HudRenderer):
|
||||
super()._update_state()
|
||||
if ENABLE_FLOATING_NAV_MAP_PANEL or ENABLE_SPLIT_NAV_MAP_PANEL:
|
||||
self.nav_map_panel.update()
|
||||
self.emac_status.update()
|
||||
self.road_name_renderer.update()
|
||||
self.speed_limit_renderer.update()
|
||||
has_limit = self.speed_limit_renderer.speed_limit_valid or self.speed_limit_renderer.speed_limit_last_valid
|
||||
@@ -65,6 +68,7 @@ class IQHudRenderer(HudRenderer):
|
||||
self.developer_ui.render(rect)
|
||||
if ENABLE_FLOATING_NAV_MAP_PANEL:
|
||||
self.nav_map_panel.render(rect)
|
||||
self.emac_status.render(rect)
|
||||
self.road_name_renderer.render(torque_rect)
|
||||
self.turn_signal_controller.render(rect)
|
||||
self.soft_warning_renderer.render(rect)
|
||||
|
||||
66
iqpilot/ui/tests/test_big_model_status.py
Normal file
66
iqpilot/ui/tests/test_big_model_status.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
|
||||
The BIG (tici/tizi) and SMALL (mici) onroad source indicators share one
|
||||
resolver (comma PR #38492 states, backend-aware label). Pin its truth table.
|
||||
"""
|
||||
from iqpilot.ui.onroad.big_model_status import SourceState, resolve_source
|
||||
|
||||
|
||||
class _FakeParams:
|
||||
def __init__(self, **flags):
|
||||
self._flags = flags
|
||||
|
||||
def get_bool(self, key: str) -> bool:
|
||||
return bool(self._flags.get(key, False))
|
||||
|
||||
|
||||
def _resolve(engaged=False, **flags):
|
||||
return resolve_source(_FakeParams(**flags), engaged)
|
||||
|
||||
|
||||
def test_no_backend_enabled_is_hidden():
|
||||
assert _resolve() == ("", SourceState.HIDDEN)
|
||||
|
||||
|
||||
def test_emac_label_is_mac():
|
||||
label, _ = _resolve(IQEmacEnabled=True, MacModelReachable=True, MacModelActive=True)
|
||||
assert label == "MAC"
|
||||
|
||||
|
||||
def test_egpu_label_is_gpu():
|
||||
label, _ = _resolve(IQEgpuEnabled=True, UsbGpuPresent=True, UsbGpuActive=True)
|
||||
assert label == "GPU"
|
||||
|
||||
|
||||
def test_emac_wins_when_both_enabled():
|
||||
label, _ = _resolve(IQEmacEnabled=True, IQEgpuEnabled=True,
|
||||
MacModelReachable=True, UsbGpuPresent=True)
|
||||
assert label == "MAC"
|
||||
|
||||
|
||||
def test_egpu_states_mirror_emac():
|
||||
assert _resolve(IQEgpuEnabled=True)[1] == SourceState.HIDDEN # not present
|
||||
assert _resolve(IQEgpuEnabled=True, UsbGpuPresent=True)[1] == SourceState.LOADING
|
||||
assert _resolve(IQEgpuEnabled=True, UsbGpuPresent=True, UsbGpuActive=True)[1] == SourceState.ACTIVE
|
||||
assert _resolve(IQEgpuEnabled=True, UsbGpuPresent=True, UsbGpuFailed=True)[1] == SourceState.FAILED
|
||||
assert _resolve(engaged=True, IQEgpuEnabled=True, UsbGpuPresent=True,
|
||||
UsbGpuFailed=True)[1] == SourceState.CROSSED
|
||||
|
||||
|
||||
def test_emac_states():
|
||||
assert _resolve(IQEmacEnabled=True)[1] == SourceState.HIDDEN # unreachable
|
||||
assert _resolve(IQEmacEnabled=True, MacModelReachable=True)[1] == SourceState.LOADING
|
||||
assert _resolve(IQEmacEnabled=True, MacModelReachable=True, MacModelActive=True)[1] == SourceState.ACTIVE
|
||||
assert _resolve(IQEmacEnabled=True, MacModelReachable=True, MacModelFailed=True)[1] == SourceState.FAILED
|
||||
# engaged on small because big failed -> crossed
|
||||
assert _resolve(engaged=True, IQEmacEnabled=True, MacModelReachable=True,
|
||||
MacModelFailed=True)[1] == SourceState.CROSSED
|
||||
# active always wins over a stale failed flag
|
||||
assert _resolve(IQEmacEnabled=True, MacModelReachable=True,
|
||||
MacModelActive=True, MacModelFailed=True)[1] == SourceState.ACTIVE
|
||||
|
||||
|
||||
def test_failed_but_disconnected_is_hidden():
|
||||
# unplugged mid-drive: nothing to show, not a red/orange "failed"
|
||||
assert _resolve(IQEmacEnabled=True, MacModelReachable=False, MacModelFailed=True)[1] == SourceState.HIDDEN
|
||||
Reference in New Issue
Block a user