Compare commits

..

2 Commits

Author SHA1 Message Date
whoisdomi b9719c63fe Summer cooling curve 2026-09-07 17:22:49 -05:00
whoisdomi 7b1c9903e8 Match C4 cooling curve 2026-09-07 17:22:48 -05:00
60 changed files with 332 additions and 1280 deletions
Binary file not shown.
+1 -2
View File
@@ -18,7 +18,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"BootCount", {PERSISTENT, INT}}, {"BootCount", {PERSISTENT, INT}},
{"BluetoothAudioAddress", {PERSISTENT, STRING}}, {"BluetoothAudioAddress", {PERSISTENT, STRING}},
{"BluetoothAudioTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}}, {"BluetoothAudioTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}},
{"BluetoothDisconnectControllersOffroad", {PERSISTENT, BOOL, "0"}},
{"BluetoothEnabled", {PERSISTENT, BOOL, "0"}}, {"BluetoothEnabled", {PERSISTENT, BOOL, "0"}},
{"CalibrationParams", {PERSISTENT, BYTES}}, {"CalibrationParams", {PERSISTENT, BYTES}},
{"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}}, {"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}},
@@ -464,7 +463,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LeadDepartingAlert", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"LeadDepartingAlert", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
{"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}}, {"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}},
{"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"LeadInfo", {PERSISTENT, BOOL, "0", "0", 3}}, {"LeadInfo", {PERSISTENT, BOOL, "1", "0", 3}},
{"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}}, {"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}},
{"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}}, {"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}},
{"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}}, {"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}},
Binary file not shown.
@@ -852,6 +852,7 @@ class CarController(CarControllerBase):
CAR.CHEVROLET_VOLT_CC, CAR.CHEVROLET_VOLT_CC,
CAR.CHEVROLET_MALIBU_CC, CAR.CHEVROLET_MALIBU_CC,
CAR.CHEVROLET_MALIBU_HYBRID_CC, CAR.CHEVROLET_MALIBU_HYBRID_CC,
CAR.BUICK_LACROSSE,
} }
if (self.CP.enableGasInterceptorDEPRECATED and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and if (self.CP.enableGasInterceptorDEPRECATED and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
+2 -2
View File
@@ -408,7 +408,7 @@ class CarInterface(CarInterfaceBase):
ret.steerActuatorDelay = 0.1 # Default delay, not measured yet ret.steerActuatorDelay = 0.1 # Default delay, not measured yet
ret.steerLimitTimer = 0.4 ret.steerLimitTimer = 0.4
ret.radarTimeStepDEPRECATED = 0.15 if candidate == CAR.BUICK_LACROSSE else 0.0667 ret.radarTimeStepDEPRECATED = 0.0667 # GM radar runs at 15Hz instead of the standard 20Hz
ret.longitudinalActuatorDelay = 0.5 # large delay to initially start braking ret.longitudinalActuatorDelay = 0.5 # large delay to initially start braking
if candidate in ( if candidate in (
@@ -440,7 +440,7 @@ class CarInterface(CarInterfaceBase):
elif candidate in (CAR.BUICK_LACROSSE, CAR.BUICK_LACROSSE_ASCM, CAR.BUICK_LACROSSE_ASCM_19US): elif candidate in (CAR.BUICK_LACROSSE, CAR.BUICK_LACROSSE_ASCM, CAR.BUICK_LACROSSE_ASCM_19US):
CarInterfaceBase.configure_torque_tune(CAR.BUICK_LACROSSE, ret.lateralTuning) CarInterfaceBase.configure_torque_tune(CAR.BUICK_LACROSSE, ret.lateralTuning)
if candidate == CAR.BUICK_LACROSSE_ASCM_19US: if candidate == CAR.BUICK_LACROSSE_ASCM_19US:
ret.minSteerSpeed = 28 * CV.MPH_TO_MS ret.minSteerSpeed = 27 * CV.MPH_TO_MS
elif candidate == CAR.CADILLAC_ESCALADE: elif candidate == CAR.CADILLAC_ESCALADE:
ret.minEnableSpeed = -1. # engage speed is decided by pcm ret.minEnableSpeed = -1. # engage speed is decided by pcm
@@ -205,33 +205,6 @@ class TestBoltGps:
class TestGMInterface: class TestGMInterface:
def test_lacrosse_obd_and_ascm_integrations_remain_separate(self):
obd_params = interfaces[CAR.BUICK_LACROSSE].get_params(
CAR.BUICK_LACROSSE,
_empty_fingerprint(),
[],
alpha_long=False,
is_release=False,
docs=False,
starpilot_toggles=_test_starpilot_toggles(),
)
ascm_params = interfaces[CAR.BUICK_LACROSSE_ASCM].get_params(
CAR.BUICK_LACROSSE_ASCM,
_empty_fingerprint(),
[],
alpha_long=False,
is_release=False,
docs=False,
starpilot_toggles=_test_starpilot_toggles(),
)
assert obd_params.networkLocation == structs.CarParams.NetworkLocation.gateway
assert obd_params.openpilotLongitudinalControl
assert obd_params.radarTimeStepDEPRECATED == pytest.approx(0.15)
assert ascm_params.networkLocation == structs.CarParams.NetworkLocation.fwdCamera
assert not ascm_params.openpilotLongitudinalControl
assert ascm_params.radarTimeStepDEPRECATED == pytest.approx(0.0667)
@parameterized.expand([ @parameterized.expand([
CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2017,
CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2018_2021,
@@ -318,14 +291,6 @@ class TestGMInterface:
assert car_params.minSteerSpeed == pytest.approx(7 * CV.MPH_TO_MS) assert car_params.minSteerSpeed == pytest.approx(7 * CV.MPH_TO_MS)
def test_lacrosse_2019_ascm_min_steer_speed_is_28_mph(self):
car_model = CAR.BUICK_LACROSSE_ASCM_19US
CarInterface = interfaces[car_model]
car_params = CarInterface.get_params(car_model, _empty_fingerprint(), [], alpha_long=False, is_release=False, docs=False,
starpilot_toggles=_test_starpilot_toggles())
assert car_params.minSteerSpeed == pytest.approx(28 * CV.MPH_TO_MS)
@parameterized.expand([ @parameterized.expand([
("interceptor", True), ("interceptor", True),
("ascm_int", False), ("ascm_int", False),
@@ -860,9 +860,7 @@ class CarController(CarControllerBase):
lka_steering = self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING lka_steering = self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING
longitudinal_active = bool(self.long_active_ecu and getattr(CC, "longActive", False)) longitudinal_active = bool(self.long_active_ecu and getattr(CC, "longActive", False))
lfa_status_cars = (CAR.HYUNDAI_IONIQ_6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) lfa_longitudinal_active = longitudinal_active if self.CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else self.CP.openpilotLongitudinalControl
lfa_longitudinal_active = self.CP.openpilotLongitudinalControl \
if self.CP.carFingerprint in lfa_status_cars else longitudinal_active
lka_steering_long = lka_steering and lfa_longitudinal_active lka_steering_long = lka_steering and lfa_longitudinal_active
ccnc_non_hda2 = self.CP.flags & HyundaiFlags.CCNC and not lka_steering ccnc_non_hda2 = self.CP.flags & HyundaiFlags.CCNC and not lka_steering
use_egmp_dynamic_long_tuning = egmp_dynamic_longitudinal_tuning(self.CP) and self.long_active_ecu and \ use_egmp_dynamic_long_tuning = egmp_dynamic_longitudinal_tuning(self.CP) and self.long_active_ecu and \
@@ -892,8 +890,7 @@ class CarController(CarControllerBase):
if angle_lkas_alt: if angle_lkas_alt:
steering_msg_active = bool(steering_msg_active and drive_gear) steering_msg_active = bool(steering_msg_active and drive_gear)
angle_lkas_alt_standstill_handoff = bool(getattr(CS.out, "standstill", False) and not CC.latActive) angle_lkas_alt_standstill_handoff = bool(getattr(CS.out, "standstill", False) and not CC.latActive)
forward_stock_lkas = (self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR or forward_stock_lkas = self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR and angle_lkas_alt and (
self.CP.carFingerprint == CAR.KIA_SPORTAGE_HEV_2026) and angle_lkas_alt and (
angle_lkas_alt_standstill_handoff or not (drive_gear and (CC.latActive or CC.enabled)) angle_lkas_alt_standstill_handoff or not (drive_gear and (CC.latActive or CC.enabled))
) )
preserve_stock_lfa_status = preserve_stock_canfd_lfa_status(self.CP.carFingerprint) preserve_stock_lfa_status = preserve_stock_canfd_lfa_status(self.CP.carFingerprint)
@@ -2484,11 +2484,10 @@ class TestHyundaiFingerprint:
CP = CarParams.new_message() CP = CarParams.new_message()
CP.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN CP.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEERING) CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEERING)
CP.openpilotLongitudinalControl = True CP.openpilotLongitudinalControl = False
controller = CarController(DBC[CP.carFingerprint], CP) controller = CarController(DBC[CP.carFingerprint], CP)
controller.frame = 1 controller.frame = 1
controller.long_active_ecu = True
can_bus = CanBus(CP) can_bus = CanBus(CP)
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS", 0)], can_bus.ACAN) parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS", 0)], can_bus.ACAN)
stock_lkas = { stock_lkas = {
@@ -2530,6 +2529,7 @@ class TestHyundaiFingerprint:
assert parser.vl["LKAS"]["STEER_MODE"] == 0 assert parser.vl["LKAS"]["STEER_MODE"] == 0
assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0 assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0
CP.openpilotLongitudinalControl = True
lfa_parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LFA", 0)], can_bus.ECAN) lfa_parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LFA", 0)], can_bus.ECAN)
lfa_msgs = hyundaicanfd.create_steering_messages(controller.packer, CP, can_bus, True, True, 0, 0.0) lfa_msgs = hyundaicanfd.create_steering_messages(controller.packer, CP, can_bus, True, True, 0, 0.0)
assert [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in lfa_msgs] == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)] assert [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in lfa_msgs] == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)]
@@ -2537,12 +2537,13 @@ class TestHyundaiFingerprint:
assert lfa_parser.can_valid assert lfa_parser.can_valid
assert lfa_parser.vl["LFA"]["DAMP_FACTOR"] == 100 assert lfa_parser.vl["LFA"]["DAMP_FACTOR"] == 100
controller.long_active_ecu = True
cc.longActive = False cc.longActive = False
inactive_msgs = controller.create_canfd_msgs(0, True, 0.44, 0.0, 0.0, 0.0, False, inactive_msgs = controller.create_canfd_msgs(0, True, 0.44, 0.0, 0.0, 0.0, False,
cc.hudControl, cs, cc, get_test_toggles(), lka_icon=2, lfa_icon=2) cc.hudControl, cs, cc, get_test_toggles(), lka_icon=2, lfa_icon=2)
steering_names = [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in inactive_msgs steering_names = [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in inactive_msgs
if controller.packer.dbc.addr_to_msg[addr].name in ("LFA", "LKAS")] if controller.packer.dbc.addr_to_msg[addr].name in ("LFA", "LKAS")]
assert steering_names == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)] assert steering_names == [("LKAS", can_bus.ACAN)]
controller.frame = 1 controller.frame = 1
cc.longActive = True cc.longActive = True
@@ -2707,7 +2708,7 @@ class TestHyundaiFingerprint:
assert len([msg for msg in msgs if msg[0] == 0x110]) == expected_lkas_msgs assert len([msg for msg in msgs if msg[0] == 0x110]) == expected_lkas_msgs
@pytest.mark.parametrize("standstill", [False, True]) @pytest.mark.parametrize("standstill", [False, True])
def test_sportage_angle_lkas_alt_forwards_stock_status_when_inactive(self, standstill): def test_sportage_angle_lkas_alt_keeps_inactive_status_in_drive(self, standstill):
CP = CarParams.new_message() CP = CarParams.new_message()
CP.carFingerprint = CAR.KIA_SPORTAGE_HEV_2026 CP.carFingerprint = CAR.KIA_SPORTAGE_HEV_2026
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ANGLE_STEERING | CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ANGLE_STEERING |
@@ -2715,16 +2716,60 @@ class TestHyundaiFingerprint:
CP.openpilotLongitudinalControl = False CP.openpilotLongitudinalControl = False
controller = CarController(DBC[CP.carFingerprint], CP) controller = CarController(DBC[CP.carFingerprint], CP)
can_bus = CanBus(CP)
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS_ALT", 0)], can_bus.ACAN)
stock_lkas = {
"CHECKSUM": 1234,
"COUNTER": 42,
"LKA_OptUsmSta": 2,
"LKA_MODE": 2,
"LKA_RcgSta": 3,
"LKA_AVAILABLE": 3,
"LKA_LHLnWrnSta": 3,
"LKA_RHLnWrnSta": 3,
"LKA_WARNING": 1,
"LKA_HndsoffSnd": 1,
"LKA_StrSnd": 1,
"LKA_SysIndReq": 4,
"LKA_ICON": 2,
"FCA_SYSWARN": 1,
"StrTqReqVal": 17,
"TORQUE_REQUEST": 17,
"ActToiSta": 3,
"STEER_REQ": 1,
"ToiFltSta": 3,
"LFA_BUTTON": 1,
"LKA_SysWrn": 15,
"LKA_ASSIST": 1,
"Damping_Gain": 0,
"STEER_MODE": 5,
"NEW_SIGNAL_2": 0,
"LKAS_ANGLE_ACTIVE": 2,
"LKA_UsmMod": 3,
"HAS_LANE_SAFETY": 1,
"ADAS_StrAnglReqVal": 12.3,
"ADAS_ACIAnglTqRedcGainVal": 0.42,
"DAMP_FACTOR": 0,
}
cc = SimpleNamespace(enabled=False, latActive=False, cc = SimpleNamespace(enabled=False, latActive=False,
actuators=SimpleNamespace(longControlState=LongCtrlState.off), actuators=SimpleNamespace(longControlState=LongCtrlState.off),
leftBlinker=False, rightBlinker=False, hudControl=SimpleNamespace()) leftBlinker=False, rightBlinker=False, hudControl=SimpleNamespace())
cs = SimpleNamespace(stock_lfa_msg=None, stock_lkas_msg={}, cs = SimpleNamespace(stock_lfa_msg=None, stock_lkas_msg=stock_lkas,
out=SimpleNamespace(standstill=standstill, steeringAngleDeg=0.0, out=SimpleNamespace(standstill=standstill, steeringAngleDeg=0.0,
gearShifter=structs.CarState.GearShifter.drive)) gearShifter=structs.CarState.GearShifter.drive))
msgs = controller.create_canfd_msgs(0, False, 0.0, 0.0, 0.0, 0.0, False, cc.hudControl, cs, cc, msgs = controller.create_canfd_msgs(0, False, 0.0, 0.0, 0.0, 0.0, False, cc.hudControl, cs, cc,
get_test_toggles(), lka_icon=1, lfa_icon=1) get_test_toggles(), lka_icon=1, lfa_icon=1)
assert not [msg for msg in msgs if msg[0] in (0x110, 0x12A)] lkas_msgs = [msg for msg in msgs if msg[0] == 0x110]
assert len(lkas_msgs) == 1
parser.update([(1, lkas_msgs)])
assert parser.can_valid
assert parser.vl["LKAS_ALT"]["LKA_StrSnd"] == 2
assert parser.vl["LKAS_ALT"]["LKA_SysIndReq"] == 1
assert parser.vl["LKAS_ALT"]["LKA_RcgSta"] == 0
assert parser.vl["LKAS_ALT"]["LKA_AVAILABLE"] == 0
assert parser.vl["LKAS_ALT"]["LKAS_ANGLE_ACTIVE"] == 1
def test_ev9_inactive_angle_steering_does_not_suppress_stock_lfa(self): def test_ev9_inactive_angle_steering_does_not_suppress_stock_lfa(self):
CP = CarParams.new_message() CP = CarParams.new_message()
@@ -77,7 +77,7 @@ class CarController(CarControllerBase):
self.angle_bus = CanBus.angle_for_cp(CP) self.angle_bus = CanBus.angle_for_cp(CP)
self.status_bus = CanBus.camera if CP.flags & SubaruFlags.D_PLATFORM_CAMERA else CanBus.main self.status_bus = CanBus.camera if CP.flags & SubaruFlags.D_PLATFORM_CAMERA else CanBus.main
if CP.flags & SubaruFlags.LKAS_ANGLE and CP.carFingerprint != CAR.SUBARU_OUTBACK_2023: if CP.flags & SubaruFlags.LKAS_ANGLE:
self.VM = VehicleModel(get_safety_CP()) self.VM = VehicleModel(get_safety_CP())
self.prev_close_distance = 0 self.prev_close_distance = 0
@@ -332,7 +332,7 @@ class CarController(CarControllerBase):
self.apply_steer_last = CS.out.steeringAngleDeg self.apply_steer_last = CS.out.steeringAngleDeg
steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lkas_active else CC.actuators.steeringAngleDeg steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lkas_active else CC.actuators.steeringAngleDeg
if self.CP.carFingerprint in (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): if self.CP.carFingerprint == CAR.SUBARU_ASCENT_2023:
apply_steer = apply_std_steer_angle_limits( apply_steer = apply_std_steer_angle_limits(
steer_target, steer_target,
self.apply_steer_last, self.apply_steer_last,
+1 -1
View File
@@ -42,7 +42,7 @@ class CarInterface(CarInterfaceBase):
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value
if candidate in SUBARU_STOP_START_CARS: if candidate in SUBARU_STOP_START_CARS:
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value
if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023):
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value
ret.steerLimitTimer = 0.4 ret.steerLimitTimer = 0.4
@@ -244,7 +244,7 @@ def test_outback_2023_uses_d_platform_bus_layout():
assert CP.flags & SubaruFlags.D_PLATFORM assert CP.flags & SubaruFlags.D_PLATFORM
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS)
assert CanBus.main_for_cp(CP) == CanBus.alt assert CanBus.main_for_cp(CP) == CanBus.alt
assert CanBus.angle_for_cp(CP) == CanBus.main assert CanBus.angle_for_cp(CP) == CanBus.main
assert parsers[Bus.pt].bus == CanBus.alt assert parsers[Bus.pt].bus == CanBus.alt
@@ -622,9 +622,8 @@ def test_angle_controller_blocks_low_speed_mads_engagement():
assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1 assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1
@pytest.mark.parametrize("platform", (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023)) def test_ascent_angle_controller_uses_fixed_angle_rate_limits():
def test_angle_controller_uses_fixed_angle_rate_limits(platform): CP = CarInterface.get_non_essential_params(CAR.SUBARU_ASCENT_2023)
CP = CarInterface.get_non_essential_params(platform)
controller = CarController({}, CP) controller = CarController({}, CP)
CC = SimpleNamespace(enabled=True, latActive=True, actuators=SimpleNamespace(steeringAngleDeg=-14.88)) CC = SimpleNamespace(enabled=True, latActive=True, actuators=SimpleNamespace(steeringAngleDeg=-14.88))
CS = SimpleNamespace(out=SimpleNamespace( CS = SimpleNamespace(out=SimpleNamespace(
@@ -417,18 +417,6 @@ class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, Test
return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle}) return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle})
class TestSubaruDPlatformFixedAngleSafety(TestSubaruDPlatformAngleSafety):
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \
SubaruSafetyFlags.FIXED_ANGLE_LIMITS
STEER_ANGLE_MAX = 545
ANGLE_RATE_BP = [0., 5., 35.]
ANGLE_RATE_UP = [5., .8, .15]
ANGLE_RATE_DOWN = [5., .8, .15]
def test_rt_limits(self):
raise unittest.SkipTest("Breakpoint angle limits do not enforce a real-time message frequency")
class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety): class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety):
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \ FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \
SubaruSafetyFlags.STOP_START_BUTTON SubaruSafetyFlags.STOP_START_BUTTON
+3 -6
View File
@@ -284,22 +284,19 @@ ensure_host_python_extensions() {
} }
sync_host_generated_headers() { sync_host_generated_headers() {
local capnpc="${ROOT_DIR}/.venv/bin/capnpc" if ! command -v capnpc >/dev/null 2>&1; then
local capnpc_cpp
capnpc_cpp="$(find "${ROOT_DIR}/.venv/lib" -path '*/capnproto/install/bin/capnpc-c++' -type f -print -quit)"
if [[ ! -x "${capnpc}" || ! -x "${capnpc_cpp}" ]]; then
return return
fi fi
( (
cd "${WORK_DIR}" cd "${WORK_DIR}"
mkdir -p cereal/gen/cpp mkdir -p cereal/gen/cpp
"${capnpc}" --src-prefix=cereal \ capnpc --src-prefix=cereal \
cereal/log.capnp \ cereal/log.capnp \
cereal/car.capnp \ cereal/car.capnp \
cereal/legacy.capnp \ cereal/legacy.capnp \
cereal/custom.capnp \ cereal/custom.capnp \
-o "${capnpc_cpp}:cereal/gen/cpp/" -o c++:cereal/gen/cpp/
) )
} }
+4
View File
@@ -69,6 +69,10 @@ def build_compile_env(*, supercombo: bool = False) -> dict[str, str]:
int(str(env.get(key)), 0) int(str(env.get(key)), 0)
except (TypeError, ValueError): except (TypeError, ValueError):
env[key] = default env[key] = default
if supercombo:
# Unified supercombo artifacts must use upstream compile defaults. The
# legacy QCOM tuning causes a reproducible HCQ timeline failure here.
env.pop("QCOM_PRIORITY", None)
return env return env
+2 -12
View File
@@ -72,11 +72,7 @@ class VCruiseHelper:
return short_interval, long_interval return short_interval, long_interval
def _uses_software_cruise(self) -> bool: def _uses_software_cruise(self) -> bool:
# Some cars, including Toyota TSS2, keep pcmCruise enabled while return bool(self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise)
# openpilot owns longitudinal control. In that case the software cruise
# target must be used so custom short/hold intervals are honored.
return bool(self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise or
getattr(self.CP, "openpilotLongitudinalControl", False))
@property @property
def v_cruise_initialized(self): def v_cruise_initialized(self):
@@ -218,7 +214,7 @@ class VCruiseHelper:
engage_floor_kph = max(V_CRUISE_MIN, 7.0 * CV.MPH_TO_KPH) engage_floor_kph = max(V_CRUISE_MIN, 7.0 * CV.MPH_TO_KPH)
resume_pressed = any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) resume_pressed = any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents)
remembered_resume = resume_prev_button and self._uses_software_cruise() remembered_resume = resume_prev_button and (self.gm_cc_only or self.redneck_non_pcm)
if self.v_cruise_initialized and (resume_pressed or remembered_resume): if self.v_cruise_initialized and (resume_pressed or remembered_resume):
self.v_cruise_kph = self.v_cruise_kph_last self.v_cruise_kph = self.v_cruise_kph_last
@@ -229,12 +225,6 @@ class VCruiseHelper:
self.v_cruise_kph = float(np.clip(initialized_speed_limit_kph, V_CRUISE_MIN, V_CRUISE_MAX)) self.v_cruise_kph = float(np.clip(initialized_speed_limit_kph, V_CRUISE_MIN, V_CRUISE_MAX))
elif self.redneck_non_pcm and CS.cruiseState.speedCluster > 0: elif self.redneck_non_pcm and CS.cruiseState.speedCluster > 0:
self.v_cruise_kph = float(np.clip(CS.cruiseState.speedCluster * CV.MS_TO_KPH, V_CRUISE_MIN, V_CRUISE_MAX)) self.v_cruise_kph = float(np.clip(CS.cruiseState.speedCluster * CV.MS_TO_KPH, V_CRUISE_MIN, V_CRUISE_MAX))
elif self.CP.pcmCruise and CS.cruiseState.speed > 0:
# Keep PCM/dash set speed as the starting target when software cruise
# takes over, while allowing subsequent button presses to use custom
# intervals. This preserves Toyota's stock engage behavior.
pcm_speed_kph = CS.cruiseState.speed * CV.MS_TO_KPH
self.v_cruise_kph = float(np.clip(pcm_speed_kph, V_CRUISE_MIN, V_CRUISE_MAX))
else: else:
self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, engage_floor_kph, V_CRUISE_MAX))) self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, engage_floor_kph, V_CRUISE_MAX)))
+3 -74
View File
@@ -313,22 +313,6 @@ class TestVCruiseHelper:
assert V_CRUISE_MIN <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX assert V_CRUISE_MIN <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX
assert self.v_cruise_helper.v_cruise_initialized assert self.v_cruise_helper.v_cruise_initialized
def test_resume_keeps_previous_software_cruise_speed(self):
engage_cs = car.CarState(vEgo=75 * CV.MPH_TO_MS)
self.v_cruise_helper.initialize_v_cruise(engage_cs, experimental_mode=False, resume_prev_button=False,
starpilot_toggles=self.starpilot_toggles)
disabled_cs = car.CarState(cruiseState={"available": True})
self.v_cruise_helper.update_v_cruise(disabled_cs, enabled=False, is_metric=False,
speed_limit_changed=False, starpilot_toggles=self.starpilot_toggles)
resume_cs = car.CarState(vEgo=22 * CV.MPH_TO_MS)
self.v_cruise_helper.initialize_v_cruise(resume_cs, experimental_mode=False, resume_prev_button=True,
starpilot_toggles=self.starpilot_toggles)
assert self.v_cruise_helper.v_cruise_kph == pytest.approx(75 * CV.MPH_TO_KPH)
assert self.v_cruise_helper.v_cruise_cluster_kph == pytest.approx(75 * CV.MPH_TO_KPH)
def test_initialize_v_cruise_matches_speed_limit(self): def test_initialize_v_cruise_matches_speed_limit(self):
self.reset_cruise_speed_state() self.reset_cruise_speed_state()
self.starpilot_toggles.set_speed_limit = True self.starpilot_toggles.set_speed_limit = True
@@ -498,8 +482,9 @@ class TestVCruiseHelper:
assert self.v_cruise_helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + IMPERIAL_INCREMENT) assert self.v_cruise_helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + IMPERIAL_INCREMENT)
def test_pcm_cruise_uses_pcm_speed(self): @pytest.mark.parametrize("openpilot_longitudinal", [False, True])
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=False) def test_pcm_cruise_uses_pcm_speed(self, openpilot_longitudinal):
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=openpilot_longitudinal)
helper = VCruiseHelper(CP) helper = VCruiseHelper(CP)
toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False) toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False)
pcm_speed_kph = 72.0 pcm_speed_kph = 72.0
@@ -532,62 +517,6 @@ class TestVCruiseHelper:
helper.update_v_cruise(next_cs, True, True, False, toggles) helper.update_v_cruise(next_cs, True, True, False, toggles)
assert helper.v_cruise_kph == pytest.approx(next_pcm_speed_kph) assert helper.v_cruise_kph == pytest.approx(next_pcm_speed_kph)
def test_openpilot_longitudinal_pcm_cruise_uses_custom_intervals(self):
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True)
helper = VCruiseHelper(CP)
toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False)
initial_speed_kph = 40.0
helper.initialize_v_cruise(car.CarState(vEgo=initial_speed_kph * CV.KPH_TO_MS), False, False, toggles)
assert helper.v_cruise_kph == pytest.approx(initial_speed_kph)
press_cs = car.CarState(
cruiseState={"available": True},
buttonEvents=[{"type": ButtonType.accelCruise, "pressed": True}],
)
helper.update_v_cruise(press_cs, True, True, False, toggles)
release_cs = car.CarState(
cruiseState={"available": True},
buttonEvents=[{"type": ButtonType.accelCruise, "pressed": False}],
)
helper.update_v_cruise(release_cs, True, True, False, toggles)
assert helper.v_cruise_kph == pytest.approx(initial_speed_kph + 5)
helper.update_v_cruise(press_cs, True, True, False, toggles)
for _ in range(50):
helper.update_v_cruise(car.CarState(cruiseState={"available": True}), True, True, False, toggles)
assert helper.v_cruise_kph == pytest.approx(initial_speed_kph + 5 + 1)
def test_openpilot_longitudinal_pcm_cruise_starts_from_pcm_set_speed(self):
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True)
helper = VCruiseHelper(CP)
toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False)
pcm_speed_kph = 72.0
helper.initialize_v_cruise(
car.CarState(
vEgo=40 * CV.KPH_TO_MS,
cruiseState={"available": True, "speed": pcm_speed_kph * CV.KPH_TO_MS},
),
False,
False,
toggles,
)
assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph)
helper.update_v_cruise(
car.CarState(
cruiseState={"available": True, "speed": 74 * CV.KPH_TO_MS},
),
True,
True,
False,
toggles,
)
assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph)
class TestVCruiseHelperRedneck: class TestVCruiseHelperRedneck:
def setup_method(self): def setup_method(self):
@@ -275,16 +275,16 @@ GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30
GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08 GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.26 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.16
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.75 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.25
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.30 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.25
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12
GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.22
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10
GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0 GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0
@@ -307,7 +307,7 @@ GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT = 0.14
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT_WIDTH = 0.05 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT_WIDTH = 0.05
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED = 6.0 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED = 6.0
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED_WIDTH = 1.5 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED_WIDTH = 1.5
GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX = 0.08 GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST = 0.00
GENESIS_G70_CURVE_UNWIND_SPEED = 18.0 GENESIS_G70_CURVE_UNWIND_SPEED = 18.0
GENESIS_G70_CURVE_UNWIND_SPEED_WIDTH = 3.0 GENESIS_G70_CURVE_UNWIND_SPEED_WIDTH = 3.0
GENESIS_G70_CURVE_UNWIND_LAT = 0.25 GENESIS_G70_CURVE_UNWIND_LAT = 0.25
@@ -3294,8 +3294,7 @@ def get_genesis_g70_curve_unwind_output_scale(desired_lateral_accel: float, desi
GENESIS_G70_CURVE_UNWIND_LAT_WIDTH) GENESIS_G70_CURVE_UNWIND_LAT_WIDTH)
jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_JERK) / jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_JERK) /
GENESIS_G70_CURVE_UNWIND_JERK_WIDTH) GENESIS_G70_CURVE_UNWIND_JERK_WIDTH)
reduction = (GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX * speed_weight * lateral_weight * jerk_weight) return 1.0 + GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST * speed_weight * lateral_weight * jerk_weight
return 1.0 - reduction
def get_genesis_g70_unwind_ff_scale(setpoint: float, measured_lateral_accel: float, def get_genesis_g70_unwind_ff_scale(setpoint: float, measured_lateral_accel: float,
@@ -56,10 +56,6 @@ HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_EGO_SPEED = 2.0
HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_SPEED = 0.5 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_SPEED = 0.5
HYUNDAI_ELANTRA_STOPPED_LEAD_MIN_CLOSING_SPEED = 0.25 HYUNDAI_ELANTRA_STOPPED_LEAD_MIN_CLOSING_SPEED = 0.25
HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_CREEP_ACCEL = 0.05 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_CREEP_ACCEL = 0.05
HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED = 1.0
HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED]
HYUNDAI_ELANTRA_FINAL_STOP_CAP_V = [-0.20, -0.25, -0.35, -0.55]
HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN = 0.45
HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0 HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED]
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90]
@@ -173,21 +169,7 @@ class LongControlVehicleTuning:
self.subaru_stop_release_frames = 0 self.subaru_stop_release_frames = 0
def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel): def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel):
"""Shape low-speed stop braking without overriding urgent targets.""" """Release a stale hard lead brake once the stop target has eased."""
if (
self.is_hyundai_elantra_2021 and
should_stop and
v_ego < HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED and
a_target <= 0.1
):
final_stop_cap = float(interp(
v_ego,
HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP,
HYUNDAI_ELANTRA_FINAL_STOP_CAP_V,
))
if a_target > final_stop_cap - HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN:
return max(float(output_accel), final_stop_cap)
if ( if (
self.is_hyundai_santa_fe_2022 and self.is_hyundai_santa_fe_2022 and
v_ego <= HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED and v_ego <= HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED and
@@ -397,27 +397,6 @@ def get_vehicle_min_accel(CP, v_ego):
return float(ACCEL_MIN) return float(ACCEL_MIN)
def get_far_lead_coast_cap(lead, v_ego, desired_gap, output_a_target):
if lead is None or not bool(getattr(lead, "status", False)):
return float(output_a_target)
v_ego = float(v_ego)
lead_distance = float(getattr(lead, "dRel", float("inf")))
lead_speed = float(getattr(lead, "vLead", v_ego))
closing_speed = v_ego - lead_speed
if (
v_ego <= 10.0 or
closing_speed <= 0.5 or
lead_distance < FAR_LEAD_COAST_MIN_DISTANCE or
lead_distance <= float(desired_gap) + FAR_LEAD_COAST_MIN_GAP_MARGIN or
lead_distance / max(closing_speed, 0.1) < FAR_LEAD_COAST_MIN_TTC or
max(0.0, -float(getattr(lead, "aLeadK", 0.0))) > FAR_LEAD_COAST_MAX_LEAD_BRAKE
):
return float(output_a_target)
return max(float(output_a_target), -FAR_LEAD_COAST_MAX_DECEL)
# Restored planner constants retained by CEM, stop, and departure paths. # Restored planner constants retained by CEM, stop, and departure paths.
A_CRUISE_MIN = -1.0 A_CRUISE_MIN = -1.0
# The stop distance runs ~9 m long through the mid-approach, which leaves the obstacle slack # The stop distance runs ~9 m long through the mid-approach, which leaves the obstacle slack
@@ -454,11 +433,6 @@ VEHICLE_FAR_FOLLOW_SLEW_MIN_DISTANCE_TIME = 1.35
VEHICLE_FAR_FOLLOW_SLEW_MIN_HEADWAY = 1.35 VEHICLE_FAR_FOLLOW_SLEW_MIN_HEADWAY = 1.35
VEHICLE_FAR_FOLLOW_SLEW_MIN_TTC = 8.0 VEHICLE_FAR_FOLLOW_SLEW_MIN_TTC = 8.0
VEHICLE_FAR_FOLLOW_SLEW_MAX_LATERAL_OFFSET = 1.5 VEHICLE_FAR_FOLLOW_SLEW_MAX_LATERAL_OFFSET = 1.5
FAR_LEAD_COAST_MIN_DISTANCE = 45.0
FAR_LEAD_COAST_MIN_TTC = 8.0
FAR_LEAD_COAST_MIN_GAP_MARGIN = 6.0
FAR_LEAD_COAST_MAX_LEAD_BRAKE = 0.35
FAR_LEAD_COAST_MAX_DECEL = 0.20
RADAR_DEPART_CONFLICT_MAX_EGO_SPEED = 1.6 RADAR_DEPART_CONFLICT_MAX_EGO_SPEED = 1.6
RADAR_DEPART_CONFLICT_MIN_RADAR_LATERAL = 1.5 RADAR_DEPART_CONFLICT_MIN_RADAR_LATERAL = 1.5
RADAR_DEPART_CONFLICT_MAX_RADAR_DISTANCE = 18.0 RADAR_DEPART_CONFLICT_MAX_RADAR_DISTANCE = 18.0
@@ -3095,28 +3069,6 @@ class LongitudinalPlanner:
panic_bypass, panic_bypass,
) )
far_lead_coast_allowed = (
not experimental_mode and
comfort_lead is not None and
desired_gap is not None and
not output_should_stop and
not vision_low_speed_stop_active and
not close_lead_caps and
not panic_bypass and
not depart_safety_veto and
inside_gap_closing_cap is None and
not bool(getattr(sm['starpilotPlan'], 'forcingStop', False)) and
not bool(getattr(sm['starpilotPlan'], 'redLight', False)) and
not bool(getattr(sm['starpilotPlan'], 'stopSignConfirmed', False))
)
if far_lead_coast_allowed:
output_a_target = get_far_lead_coast_cap(
comfort_lead,
scene_v_ego,
desired_gap,
output_a_target,
)
if radar_gap_settle_active: if radar_gap_settle_active:
output_a_target = RADAR_STANDSTILL_GAP_SETTLE_ACCEL output_a_target = RADAR_STANDSTILL_GAP_SETTLE_ACCEL
output_should_stop = False output_should_stop = False
+1 -8
View File
@@ -7,7 +7,6 @@ from typing import Any
import capnp import capnp
from cereal import messaging, log, car, custom from cereal import messaging, log, car, custom
from cereal.services import SERVICE_LIST
from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process
@@ -43,11 +42,6 @@ def is_bosch_a_radar_car(CP) -> bool:
return CP.brand == "honda" and CP.carFingerprint in HONDA_BOSCH_A and not CP.radarUnavailable return CP.brand == "honda" and CP.carFingerprint in HONDA_BOSCH_A and not CP.radarUnavailable
def has_slow_radar_tracks(CP) -> bool:
radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL)
return not CP.radarUnavailable and radar_ts > 2.0 / SERVICE_LIST["liveTracks"].frequency
# Adjacent-lane stopped-vehicle detector, used as a stop-line hint on red-light # Adjacent-lane stopped-vehicle detector, used as a stop-line hint on red-light
# approaches. The qualifier is the DECELERATION HISTORY, not the current speed: roadside # approaches. The qualifier is the DECELERATION HISTORY, not the current speed: roadside
# furniture and curb-parked cars never show a moving -> stopped transition, so testing # furniture and curb-parked cars never show a moving -> stopped transition, so testing
@@ -642,9 +636,8 @@ def main() -> None:
cloudlog.info("radard got CarParams") cloudlog.info("radard got CarParams")
# *** setup messaging # *** setup messaging
ignore_avg_freq = ['liveTracks'] if has_slow_radar_tracks(CP) else None
sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2', sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2',
ignore_avg_freq=ignore_avg_freq, ignore_valid=['starpilotPlan']) ignore_valid=['starpilotPlan'])
pm = messaging.PubMaster(['radarState']) pm = messaging.PubMaster(['radarState'])
radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL) radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL)
+1 -1
View File
@@ -960,7 +960,7 @@ class TestLatControl:
assert get_genesis_g70_low_speed_output_limit(0.0, 2.0) < 0.30 assert get_genesis_g70_low_speed_output_limit(0.0, 2.0) < 0.30
assert get_genesis_g70_low_speed_angle_damping(0.0, -20.0, 0.0, 2.0) < 0.0 assert get_genesis_g70_low_speed_angle_damping(0.0, -20.0, 0.0, 2.0) < 0.0
assert get_genesis_g70_low_speed_angle_damping(0.0, 20.0, 0.0, 2.0) > 0.0 assert get_genesis_g70_low_speed_angle_damping(0.0, 20.0, 0.0, 2.0) > 0.0
assert 0.90 < get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) < 1.0 assert get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) == pytest.approx(1.0)
assert get_genesis_g70_curve_unwind_output_scale(0.7, 0.5, 25.0) == 1.0 assert get_genesis_g70_curve_unwind_output_scale(0.7, 0.5, 25.0) == 1.0
assert get_genesis_g70_angle_output_scale(55.0, 1.0) > get_genesis_g70_angle_output_scale(85.0, 1.0) assert get_genesis_g70_angle_output_scale(55.0, 1.0) > get_genesis_g70_angle_output_scale(85.0, 1.0)
assert get_genesis_g70_angle_output_scale(85.0, -1.0) == pytest.approx(1.0) assert get_genesis_g70_angle_output_scale(85.0, -1.0) == pytest.approx(1.0)
-10
View File
@@ -14,7 +14,6 @@ from openpilot.selfdrive.controls.radard import (
RadarD, RadarD,
g90_low_speed_radar_lead_sane, g90_low_speed_radar_lead_sane,
g90_radar_lead_lateral_sane, g90_radar_lead_lateral_sane,
has_slow_radar_tracks,
is_bosch_a_radar_car, is_bosch_a_radar_car,
match_vision_to_track, match_vision_to_track,
) )
@@ -97,15 +96,6 @@ class TestLeads:
assert bosch_a.lead_prob_filters[0].dt == pytest.approx(DT_MDL) assert bosch_a.lead_prob_filters[0].dt == pytest.approx(DT_MDL)
assert bosch_a.kalman_params.A[0][1] == pytest.approx(HONDA_BOSCH_A_RADAR_TS) assert bosch_a.kalman_params.A[0][1] == pytest.approx(HONDA_BOSCH_A_RADAR_TS)
def test_slow_radar_frequency_relaxation_is_scoped(self):
slow_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.15, radarUnavailable=False)
normal_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.1, radarUnavailable=False)
unavailable_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.15, radarUnavailable=True)
assert has_slow_radar_tracks(slow_radar)
assert not has_slow_radar_tracks(normal_radar)
assert not has_slow_radar_tracks(unavailable_radar)
@pytest.mark.skipif(platform.system() == "Darwin", reason="SocketEventHandle requires eventfd") @pytest.mark.skipif(platform.system() == "Darwin", reason="SocketEventHandle requires eventfd")
def test_radar_fault(self): def test_radar_fault(self):
# if there's no radar-related can traffic, radard should either not respond or respond with an error # if there's no radar-related can traffic, radard should either not respond or respond with an error
@@ -765,15 +765,6 @@ def test_elantra_lead_stop_releases_stale_hard_brake_after_target_eases():
assert tuning.shape_stopping_accel(-1.20, -0.25, True, 1.0, False, -0.85) == pytest.approx(-1.20) assert tuning.shape_stopping_accel(-1.20, -0.25, True, 1.0, False, -0.85) == pytest.approx(-1.20)
def test_elantra_final_stop_cap_softens_normal_low_speed_stop():
CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")
tuning = vehicle_tunes.LongControlVehicleTuning(CP)
assert tuning.shape_stopping_accel(-0.85, -0.25, True, 0.5, False, -0.85) == pytest.approx(-0.35)
assert tuning.shape_stopping_accel(-0.85, -1.25, True, 0.5, False, -0.85) == pytest.approx(-0.85)
assert tuning.shape_stopping_accel(-0.85, -0.25, False, 0.5, False, -0.85) == pytest.approx(-0.85)
def test_elantra_stopped_lead_handoff_holds_braking_direction_without_touching_brakes(): def test_elantra_stopped_lead_handoff_holds_braking_direction_without_touching_brakes():
CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021") CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")
tuning = vehicle_tunes.LongControlVehicleTuning(CP) tuning = vehicle_tunes.LongControlVehicleTuning(CP)
@@ -18,13 +18,7 @@ from opendbc.car.toyota.values import CAR as TOYOTA_CAR
import openpilot.selfdrive.controls.lib.longitudinal_planner as longitudinal_planner_module import openpilot.selfdrive.controls.lib.longitudinal_planner as longitudinal_planner_module
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.selfdrive.controls.lib.longitudinal_planner import ( from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, get_coast_accel, get_vehicle_min_accel, should_publish_planner_fcw
LongitudinalPlanner,
get_coast_accel,
get_far_lead_coast_cap,
get_vehicle_min_accel,
should_publish_planner_fcw,
)
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import ( from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import (
LongitudinalMpc, LongitudinalMpc,
build_model_lead_trajectory, build_model_lead_trajectory,
@@ -316,24 +310,6 @@ def test_mpc_panic_bypass_immediately_removes_duplicate_vision_filter():
assert mpc.lead_v_filter.x == pytest.approx(10.0) assert mpc.lead_v_filter.x == pytest.approx(10.0)
def test_far_lead_coast_cap_delays_nonurgent_deceleration():
lead = make_lead(status=True, d_rel=128.0, v_lead=16.7, a_lead=0.2, radar=True)
assert get_far_lead_coast_cap(lead, 26.6, 115.0, -0.43) == pytest.approx(-0.20)
assert get_far_lead_coast_cap(lead, 26.6, 115.0, 0.10) == pytest.approx(0.10)
@pytest.mark.parametrize("d_rel,v_lead,a_lead,desired_gap", [
(50.0, 20.0, 0.2, 45.0), # only a small gap remains
(128.0, 8.0, 0.2, 115.0), # urgent closing time
(128.0, 16.7, -0.5, 115.0), # the lead is braking materially
])
def test_far_lead_coast_cap_preserves_urgent_or_close_deceleration(d_rel, v_lead, a_lead, desired_gap):
lead = make_lead(status=True, d_rel=d_rel, v_lead=v_lead, a_lead=a_lead, radar=True)
assert get_far_lead_coast_cap(lead, 26.6, desired_gap, -0.43) == pytest.approx(-0.43)
def test_hrv_far_follow_output_slew_damps_only_continuous_safe_follow(): def test_hrv_far_follow_output_slew_damps_only_continuous_safe_follow():
v_ego = 24.0 v_ego = 24.0
CP = CarInterface.get_non_essential_params(CAR.HONDA_HRV_3G) CP = CarInterface.get_non_essential_params(CAR.HONDA_HRV_3G)
@@ -63,7 +63,6 @@ class VisualsLayoutMici(NavScroller):
self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget") self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget")
self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath") self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath")
self._lead_indicator_btn = LeadIndicatorBigButton() self._lead_indicator_btn = LeadIndicatorBigButton()
self._lead_info_btn = BigParamControl("show lead speed", "LeadInfo")
self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits") self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits")
self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation") self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation")
self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower") self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower")
@@ -77,7 +76,6 @@ class VisualsLayoutMici(NavScroller):
self._torque_bar_btn, self._torque_bar_btn,
self._rainbow_path_btn, self._rainbow_path_btn,
self._lead_indicator_btn, self._lead_indicator_btn,
self._lead_info_btn,
self._speed_limit_signs_btn, self._speed_limit_signs_btn,
self._slc_confirmation_btn, self._slc_confirmation_btn,
self._slc_confirmation_lower_btn, self._slc_confirmation_lower_btn,
@@ -95,7 +93,6 @@ class VisualsLayoutMici(NavScroller):
def _refresh(self): def _refresh(self):
self._camera_view_btn.refresh() self._camera_view_btn.refresh()
self._lead_indicator_btn.refresh() self._lead_indicator_btn.refresh()
self._lead_info_btn.set_enabled(lead_indicator_enabled(self._lead_info_btn.params, hide_by_default=True))
confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation") confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation")
self._slc_confirmation_lower_btn.set_visible(confirmation_enabled) self._slc_confirmation_lower_btn.set_visible(confirmation_enabled)
self._slc_confirmation_higher_btn.set_visible(confirmation_enabled) self._slc_confirmation_higher_btn.set_visible(confirmation_enabled)
+3 -35
View File
@@ -13,9 +13,8 @@ from openpilot.selfdrive.ui.onroad.starpilot.rainbow_path import RainbowPath
from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets import Widget
CLIP_MARGIN = 500 CLIP_MARGIN = 500
@@ -67,7 +66,6 @@ class ModelRenderer(Widget):
self._lane_line_probs = np.zeros(4, dtype=np.float32) self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()] self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._lead_info_enabled = False
self._path_offset_z = HEIGHT_INIT[0] self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects # Initialize ModelPoints objects
@@ -138,7 +136,6 @@ class ModelRenderer(Widget):
model = sm['modelV2'] model = sm['modelV2']
radar_state = sm['radarState'] if sm.valid['radarState'] else None radar_state = sm['radarState'] if sm.valid['radarState'] else None
lead_one = radar_state.leadOne if radar_state else None lead_one = radar_state.leadOne if radar_state else None
self._lead_info_enabled = self._params.get_bool("LeadInfo")
render_lead_indicator = self._should_render_lead_indicator(radar_state) render_lead_indicator = self._should_render_lead_indicator(radar_state)
# Update model data when needed # Update model data when needed
@@ -162,7 +159,7 @@ class ModelRenderer(Widget):
self._draw_path(sm) self._draw_path(sm)
if render_lead_indicator and radar_state: if render_lead_indicator and radar_state:
self._draw_lead_indicator(radar_state) self._draw_lead_indicator()
def _should_render_lead_indicator(self, radar_state) -> bool: def _should_render_lead_indicator(self, radar_state) -> bool:
return radar_state is not None and lead_indicator_enabled(self._params, hide_by_default=True) return radar_state is not None and lead_indicator_enabled(self._params, hide_by_default=True)
@@ -501,7 +498,7 @@ class ModelRenderer(Widget):
] ]
draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient) draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient)
def _draw_lead_indicator(self, radar_state): def _draw_lead_indicator(self):
# Draw lead vehicles if available # Draw lead vehicles if available
lead_color = get_theme_color("LeadMarker", rl.Color(201, 34, 49, 255)) lead_color = get_theme_color("LeadMarker", rl.Color(201, 34, 49, 255))
for lead in self._lead_vehicles: for lead in self._lead_vehicles:
@@ -511,35 +508,6 @@ class ModelRenderer(Widget):
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha)) rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha))
lead_one = radar_state.leadOne
if self._lead_info_enabled and lead_one and lead_one.status:
self._draw_lead_speed(lead_one)
@staticmethod
def _format_lead_speed(lead_speed: float, is_metric: bool, use_si_metrics: bool) -> str:
lead_speed = max(float(lead_speed), 0.0)
if use_si_metrics:
return f"{round(lead_speed)} m/s"
if is_metric:
return f"{round(lead_speed * CV.MS_TO_KPH)} km/h"
return f"{round(lead_speed * CV.MS_TO_MPH)} mph"
def _draw_lead_speed(self, lead_data) -> None:
from openpilot.selfdrive.ui.onroad.starpilot.path import _draw_text_with_outline
text = self._format_lead_speed(
getattr(lead_data, "vLead", 0.0),
ui_state.is_metric,
ui_state.starpilot_toggles.get("UseSiMetrics", False),
)
font = gui_app.font(FontWeight.SEMI_BOLD)
font_size = 40
text_size = measure_text_cached(font, text, font_size)
center_x = self._rect.x + self._rect.width / 2
x = center_x - text_size.x / 2
y = self._rect.y + 22
_draw_text_with_outline(text, float(x), float(y), font, font_size)
@staticmethod @staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int:
"""Get the index corresponding to the given path height""" """Get the index corresponding to the given path height"""
+3 -44
View File
@@ -1,25 +1,19 @@
from types import SimpleNamespace from types import SimpleNamespace
import pytest
import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer
class _FakeParams: class _FakeParams:
def __init__(self, enabled: bool, lead_info: bool = False): def __init__(self, enabled: bool):
self.enabled = enabled self.enabled = enabled
self.lead_info = lead_info
def get(self, key): def get(self, key):
assert key == "HideLeadMarker" assert key == "HideLeadMarker"
return b"0" if self.enabled else b"1" return b"0" if self.enabled else b"1"
def get_bool(self, key): def get_bool(self, key):
if key == "HideLeadMarker": assert key == "HideLeadMarker"
return not self.enabled return not self.enabled
if key == "LeadInfo":
return self.lead_info
raise AssertionError(key)
def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch): def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch):
@@ -37,38 +31,3 @@ def test_lead_indicator_still_honors_disabled_setting():
assert not renderer._should_render_lead_indicator(SimpleNamespace()) assert not renderer._should_render_lead_indicator(SimpleNamespace())
assert not renderer._should_render_lead_indicator(None) assert not renderer._should_render_lead_indicator(None)
@pytest.mark.parametrize(
("is_metric", "use_si_metrics", "expected"),
[
(False, False, "22 mph"),
(True, False, "36 km/h"),
(False, True, "10 m/s"),
],
)
def test_lead_speed_uses_c3_units(is_metric, use_si_metrics, expected):
assert model_renderer.ModelRenderer._format_lead_speed(10.0, is_metric, use_si_metrics) == expected
def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch):
drawn_metrics = []
monkeypatch.setattr(model_renderer, "get_theme_color", lambda *_args: model_renderer.rl.RED)
monkeypatch.setattr(model_renderer.rl, "draw_triangle_fan", lambda *_args: None)
renderer = object.__new__(model_renderer.ModelRenderer)
renderer._lead_info_enabled = True
renderer._lead_vehicles = [
model_renderer.LeadVehicle(
glow=[(1.0, 2.0)] * 3,
chevron=[(1.0, 2.0)] * 3,
fill_alpha=255,
),
model_renderer.LeadVehicle(),
]
renderer._draw_lead_speed = drawn_metrics.append
lead_one = SimpleNamespace(status=True, vLead=10.0)
renderer._draw_lead_indicator(SimpleNamespace(leadOne=lead_one, leadTwo=SimpleNamespace(status=False)))
assert drawn_metrics == [lead_one]
+6 -1
View File
@@ -1022,6 +1022,11 @@ class ModelManager:
model_key = self._canonical_model_key(model_key) model_key = self._canonical_model_key(model_key)
accelerator = str(accelerator or "").strip().lower() accelerator = str(accelerator or "").strip().lower()
try: try:
if accelerator == MODEL_LAB_ACCELERATOR and not external_gpu_available():
handle_error(None, "External GPU required...", "Chestnut is not connected and firmware-ready.",
MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return False
artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator) artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator)
if not model_accelerator_artifact_available(model_key, accelerator): if not model_accelerator_artifact_available(model_key, accelerator):
handle_error(None, "Accelerator artifact unavailable...", handle_error(None, "Accelerator artifact unavailable...",
@@ -1054,7 +1059,7 @@ class ModelManager:
MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return False return False
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!") self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Chestnut artifact downloaded!")
return True return True
finally: finally:
self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
@@ -326,8 +326,7 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili
}, },
}]) }])
(tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata))
# These are precompiled files, so downloading must not require a connected eGPU. monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True)
monkeypatch.setattr(model_manager, "external_gpu_available", lambda: False)
monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"]) monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"])
monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {}) monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {})
calls = [] calls = []
@@ -347,7 +346,7 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili
) )
assert calls[0][3]["execution_device"] == "AMD" assert calls[0][3]["execution_device"] == "AMD"
assert calls[0][5] == ["https://models.example"] assert calls[0][5] == ["https://models.example"]
assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "eGPU variant downloaded!" assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "Chestnut artifact downloaded!"
assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values
@@ -201,8 +201,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "LaneChanges", "parent_key": "LaneChanges",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -341,8 +339,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLateral", "parent_key": "QOLLateral",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1020,8 +1016,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1034,8 +1028,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1121,8 +1113,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1700,10 +1690,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 1.0, "min": 1.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1715,10 +1701,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 1.0, "min": 1.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1792,10 +1774,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1809,8 +1787,6 @@
"max": 30.0, "max": 30.0,
"step": 0.5, "step": 0.5,
"precision": 1, "precision": 1,
"unit_type": "vehicle_speed",
"imperial_max": 15.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2247,12 +2223,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 0,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2264,12 +2234,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 1,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2281,12 +2245,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 2,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2298,12 +2256,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 3,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2315,12 +2267,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 4,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2332,12 +2278,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 5,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2349,12 +2289,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 6,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2407,8 +2341,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2421,8 +2353,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2455,8 +2385,6 @@
"min": 0.0, "min": 0.0,
"max": 15.0, "max": 15.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 30.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2526,7 +2454,6 @@
"min": 5, "min": 5,
"max": 80, "max": 80,
"step": 5, "step": 5,
"unit_type": "vehicle_speed",
"parent_key": "VisionSpeedLimitLowLimitFilter", "parent_key": "VisionSpeedLimitLowLimitFilter",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -4891,7 +4818,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"parent_key": "GalaxyDeveloperMode", "parent_key": "GalaxyDeveloperMode",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
+2 -2
View File
@@ -329,9 +329,9 @@ class BlueZClient:
self.set_device_property(address, "Trusted", "b", True) self.set_device_property(address, "Trusted", "b", True)
self.agent.clear() self.agent.clear()
def connect(self, address: str, timeout: float = 30.0) -> None: def connect(self, address: str) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Connect", timeout=timeout) self._call(device["path"], DEVICE_IFACE, "Connect", timeout=30.0)
def disconnect(self, address: str) -> None: def disconnect(self, address: str) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
+31 -115
View File
@@ -18,10 +18,8 @@ SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0 AUDIO_TEST_HOLD_TIME = 3.0
RECONNECT_INTERVAL_SECONDS = 15.0 RECONNECT_INTERVAL_SECONDS = 15.0
CONTROLLER_RECONNECT_INTERVAL_SECONDS = 5.0
RECONNECT_MAX_BACKOFF_SECONDS = 300.0 RECONNECT_MAX_BACKOFF_SECONDS = 300.0
MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0 MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0
CONTROLLER_OFFROAD_DISCONNECT_DELAY_SECONDS = 120.0
class BluetoothController: class BluetoothController:
@@ -38,9 +36,6 @@ class BluetoothController:
self._last_reconnect = 0.0 self._last_reconnect = 0.0
self._reconnect_backoff: dict[str, tuple[int, float]] = {} self._reconnect_backoff: dict[str, tuple[int, float]] = {}
self._manual_disconnect_until: dict[str, float] = {} self._manual_disconnect_until: dict[str, float] = {}
self._offroad_since: float | None = None
self._policy_disconnected: set[str] = set()
self._policy_disconnect_retry_after: dict[str, float] = {}
self._scan_deadline = 0.0 self._scan_deadline = 0.0
self._audio_test_deadline = 0.0 self._audio_test_deadline = 0.0
self._sleep = sleep self._sleep = sleep
@@ -228,8 +223,6 @@ class BluetoothController:
# report NotConnected, and it must not immediately be auto-reconnected. # report NotConnected, and it must not immediately be auto-reconnected.
self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS
self._reconnect_backoff.pop(normalized_address, None) self._reconnect_backoff.pop(normalized_address, None)
self._policy_disconnected.discard(normalized_address)
self._policy_disconnect_retry_after.pop(normalized_address, None)
try: try:
with self._lock: with self._lock:
self._client().disconnect(normalized_address) self._client().disconnect(normalized_address)
@@ -241,8 +234,6 @@ class BluetoothController:
self._client().remove(address) self._client().remove(address)
self._reconnect_backoff.pop(address.upper(), None) self._reconnect_backoff.pop(address.upper(), None)
self._manual_disconnect_until.pop(address.upper(), None) self._manual_disconnect_until.pop(address.upper(), None)
self._policy_disconnected.discard(address.upper())
self._policy_disconnect_retry_after.pop(address.upper(), None)
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper(): if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
self.params.remove("BluetoothAudioAddress") self.params.remove("BluetoothAudioAddress")
elif command == "select_audio": elif command == "select_audio":
@@ -281,122 +272,47 @@ class BluetoothController:
self._client().stop_discovery() self._client().stop_discovery()
self._scan_deadline = 0.0 self._scan_deadline = 0.0
def _maintain_controller_offroad_policy(self, status: dict[str, Any], now: float) -> bool:
if not status["offroad"]:
self._offroad_since = None
if self._policy_disconnected:
for address in self._policy_disconnected:
self._reconnect_backoff.pop(address, None)
self._policy_disconnect_retry_after.clear()
self._last_reconnect = 0.0
return False
if self._offroad_since is None:
self._offroad_since = now
if not self.params.get_bool("BluetoothDisconnectControllersOffroad"):
if self._policy_disconnected:
self._policy_disconnect_retry_after.clear()
self._last_reconnect = 0.0
return False
if now - self._offroad_since < CONTROLLER_OFFROAD_DISCONNECT_DELAY_SECONDS:
return False
for device in status["devices"]:
if not device.get("paired") or not device.get("controller") or not device.get("connected"):
continue
address = str(device["address"]).upper()
if now < self._policy_disconnect_retry_after.get(address, 0.0):
continue
self._policy_disconnected.add(address)
self._policy_disconnect_retry_after[address] = now + RECONNECT_INTERVAL_SECONDS
try:
with self._lock:
self._client().disconnect(address)
except RuntimeError as error:
if "notconnected" not in str(error).replace(" ", "").lower():
self._policy_disconnected.discard(address)
self._policy_disconnect_retry_after.pop(address, None)
cloudlog.warning(f"Bluetooth offroad controller disconnect failed for {address}: {error}")
except Exception as error:
self._policy_disconnected.discard(address)
self._policy_disconnect_retry_after.pop(address, None)
cloudlog.warning(f"Bluetooth offroad controller disconnect failed for {address}: {error}")
return True
def _maintain_reconnects(self, status: dict[str, Any], now: float, suspend_controller_reconnect: bool) -> None:
devices = status["devices"]
devices_by_address = {device["address"].upper(): device for device in devices}
for address in list(self._policy_disconnected):
device = devices_by_address.get(address)
if device is None or not device["paired"] or not device["trusted"]:
self._policy_disconnected.discard(address)
self._reconnect_backoff.pop(address, None)
elif device["connected"]:
self._policy_disconnected.discard(address)
self._reconnect_backoff.pop(address, None)
if self._pairing_address:
return
selected = str(status["selected_audio"])
candidates = [device for device in devices if device["paired"] and device["trusted"] and not device["connected"]]
candidates.sort(key=lambda device: device["address"].upper() != selected.upper())
controller_candidates = {
device["address"].upper() for device in candidates
if device["controller"] or device["address"].upper() in self._policy_disconnected
}
reconnect_interval = CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller_candidates else RECONNECT_INTERVAL_SECONDS
if now - self._last_reconnect < reconnect_interval:
return
self._last_reconnect = now
candidate_addresses = {device["address"].upper() for device in candidates}
for address in list(self._manual_disconnect_until):
if address not in candidate_addresses or now >= self._manual_disconnect_until[address]:
self._manual_disconnect_until.pop(address, None)
for address in list(self._reconnect_backoff):
if address not in candidate_addresses:
self._reconnect_backoff.pop(address, None)
for device in candidates:
address = device["address"].upper()
controller = device["controller"] or address in self._policy_disconnected
if not device["audio"] and not controller:
continue
if suspend_controller_reconnect and controller:
continue
if now < self._manual_disconnect_until.get(address, 0.0):
continue
attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0))
if now < retry_after:
continue
try:
with self._lock:
self._client().connect(address, timeout=CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else 30.0)
self._reconnect_backoff.pop(address, None)
except Exception:
attempts += 1
delay = (CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else
min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS))
self._reconnect_backoff[address] = (attempts, now + delay)
cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s")
def maintain_connections(self) -> None: def maintain_connections(self) -> None:
while True: while True:
time.sleep(2) time.sleep(2)
now = time.monotonic()
if not self.params.get_bool("BluetoothEnabled"): if not self.params.get_bool("BluetoothEnabled"):
self._maintain_controller_offroad_policy({"offroad": self._offroad(), "devices": []}, now)
continue continue
try: try:
status = self.status() status = self.status()
if not status["available"] or not status["powered"]: if not status["available"] or not status["powered"]:
continue continue
now = time.monotonic()
self._maintain_scan(status, now) self._maintain_scan(status, now)
suspend_controller_reconnect = self._maintain_controller_offroad_policy(status, now) if self._pairing_address or now - self._last_reconnect < RECONNECT_INTERVAL_SECONDS:
self._maintain_reconnects(status, now, suspend_controller_reconnect) continue
self._last_reconnect = now
selected = str(status["selected_audio"])
candidates = [device for device in status["devices"] if device["paired"] and device["trusted"] and not device["connected"]]
candidates.sort(key=lambda device: device["address"].upper() != selected.upper())
candidate_addresses = {device["address"].upper() for device in candidates}
for address in list(self._manual_disconnect_until):
if address not in candidate_addresses or now >= self._manual_disconnect_until[address]:
self._manual_disconnect_until.pop(address, None)
for address in list(self._reconnect_backoff):
if address not in candidate_addresses:
self._reconnect_backoff.pop(address, None)
for device in candidates:
if device["audio"] or device["controller"]:
address = device["address"].upper()
if now < self._manual_disconnect_until.get(address, 0.0):
continue
_attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0))
if now < retry_after:
continue
try:
with self._lock:
self._client().connect(address)
self._reconnect_backoff.pop(address, None)
except Exception:
attempts = _attempts + 1
delay = min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS)
self._reconnect_backoff[address] = (attempts, now + delay)
cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s")
except Exception: except Exception:
cloudlog.exception("Bluetooth connection maintenance failed") cloudlog.exception("Bluetooth connection maintenance failed")
@@ -55,8 +55,6 @@ class FakeBlueZ:
self.discovering = False self.discovering = False
self.closed = False self.closed = False
self.actions = [] self.actions = []
self.connect_timeouts = []
self.connect_error = None
self.device = { self.device = {
"path": "/fake/device", "path": "/fake/device",
"address": "00:11:22:33:44:55", "address": "00:11:22:33:44:55",
@@ -93,11 +91,8 @@ class FakeBlueZ:
def pair(self, address, _device_path=None): def pair(self, address, _device_path=None):
self.actions.append(("pair", address)) self.actions.append(("pair", address))
def connect(self, address, timeout=30.0): def connect(self, address):
self.actions.append(("connect", address)) self.actions.append(("connect", address))
self.connect_timeouts.append(timeout)
if self.connect_error is not None:
raise self.connect_error
def disconnect(self, address): def disconnect(self, address):
self.actions.append(("disconnect", address)) self.actions.append(("disconnect", address))
@@ -302,7 +297,6 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect():
params = FakeParams(IsOffroad=False, BluetoothEnabled=True) params = FakeParams(IsOffroad=False, BluetoothEnabled=True)
client = FakeBlueZ() client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio()) controller = BluetoothController(params, lambda: client, FakeRadio())
controller._policy_disconnected.add(client.device["address"].upper())
controller.handle({"command": "disconnect", "address": client.device["address"]}) controller.handle({"command": "disconnect", "address": client.device["address"]})
@@ -310,7 +304,6 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect():
assert client.actions == [("disconnect", client.device["address"])] assert client.actions == [("disconnect", client.device["address"])]
assert address in controller._manual_disconnect_until assert address in controller._manual_disconnect_until
assert controller._manual_disconnect_until[address] > time.monotonic() assert controller._manual_disconnect_until[address] > time.monotonic()
assert address not in controller._policy_disconnected
def test_power_off_preserves_saved_audio_selection(): def test_power_off_preserves_saved_audio_selection():
@@ -439,85 +432,6 @@ def test_scan_stops_after_timeout():
assert not client.discovering and controller._scan_deadline == 0.0 assert not client.discovering and controller._scan_deadline == 0.0
def test_controller_offroad_disconnect_policy_is_opt_in_and_delayed():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=False)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
controller_status = {
"offroad": True,
"devices": [
{**client.device, "name": "Controller", "audio": False, "controller": True, "connected": True},
{**client.device, "address": "AA:BB:CC:DD:EE:FF", "audio": True, "controller": False, "connected": True},
],
}
assert not controller._maintain_controller_offroad_policy(controller_status, 100.0)
params.put_bool("BluetoothDisconnectControllersOffroad", True)
assert not controller._maintain_controller_offroad_policy(controller_status, 219.9)
assert client.actions == []
assert controller._maintain_controller_offroad_policy(controller_status, 220.0)
assert client.actions == [("disconnect", client.device["address"])]
assert client.device["address"].upper() in controller._policy_disconnected
assert controller._maintain_controller_offroad_policy(controller_status, 221.0)
assert client.actions == [("disconnect", client.device["address"])]
def test_controller_offroad_disconnect_policy_reconnects_onroad():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=True)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
address = "00:11:22:33:44:55"
controller._offroad_since = 100.0
controller._policy_disconnected.add(address)
controller._reconnect_backoff[address] = (3, 500.0)
controller._last_reconnect = 210.0
disconnected_status = {
"offroad": False,
"selected_audio": "",
"devices": [{**client.device, "controller": False, "connected": False}],
}
assert not controller._maintain_controller_offroad_policy(disconnected_status, 220.0)
assert controller._offroad_since is None
assert controller._policy_disconnected == {address}
assert controller._policy_disconnect_retry_after == {}
assert address not in controller._reconnect_backoff
assert controller._last_reconnect == 0.0
controller._maintain_reconnects(disconnected_status, 220.0, False)
assert client.actions == [("connect", address)]
assert client.connect_timeouts == [5.0]
connected_status = {
**disconnected_status,
"devices": [{**client.device, "controller": False, "connected": True}],
}
controller._maintain_reconnects(connected_status, 221.0, False)
assert controller._policy_disconnected == set()
def test_controller_auto_reconnect_uses_fixed_short_retry():
params = FakeParams(IsOffroad=False, BluetoothEnabled=True)
client = FakeBlueZ()
client.connect_error = RuntimeError("Host is down")
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
status = {
"offroad": False,
"selected_audio": "",
"devices": [{**client.device, "audio": False, "controller": True, "connected": False}],
}
controller._maintain_reconnects(status, 100.0, False)
assert controller._reconnect_backoff[client.device["address"]] == (1, 105.0)
controller._maintain_reconnects(status, 105.0, False)
assert controller._reconnect_backoff[client.device["address"]] == (2, 110.0)
assert client.connect_timeouts == [5.0, 5.0]
def test_pair_keeps_discovery_until_pair_starts(): def test_pair_keeps_discovery_until_pair_starts():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True) params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ() client = FakeBlueZ()
@@ -3,7 +3,7 @@ import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-
import { hideSidebar } from "/assets/js/utils.js" import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1" import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15" import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-3" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
import { DoorControl } from "/assets/components/tools/doors.js" import { DoorControl } from "/assets/components/tools/doors.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
@@ -21,7 +21,7 @@ import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1"
import { SentryMode } from "/assets/components/tools/sentry.js" import { SentryMode } from "/assets/components/tools/sentry.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a"
import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-6" import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-5"
import { LivePlots } from "/assets/components/tools/plots.js" import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js" import { TestingGround } from "/assets/components/tools/testing_ground.js"
@@ -342,24 +342,6 @@
margin-bottom: var(--margin-sm); margin-bottom: var(--margin-sm);
} }
.ds-unit-note {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-muted);
display: flex;
font-size: var(--font-size-sm);
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
padding: var(--padding-sm) var(--padding-base);
}
.ds-unit-note i,
.ds-unit-note strong {
color: var(--main-fg);
}
/* ――― Empty Filter State ――― */ /* ――― Empty Filter State ――― */
.ds-empty { .ds-empty {
color: var(--text-muted); color: var(--text-muted);
@@ -1,5 +1,4 @@
import { html, reactive } from "/assets/vendor/arrow-core.js" import { html, reactive } from "/assets/vendor/arrow-core.js"
import { formatNumericParamValue, resolveVehicleUnitParam, vehicleSpeedUnit } from "/assets/mobile/js/params.js"
const endpointOptionsCache = {} const endpointOptionsCache = {}
const endpointOptionsInflight = {} const endpointOptionsInflight = {}
@@ -449,6 +448,40 @@ async function fetchLayoutAndParams() {
scheduleSyncInputs() scheduleSyncInputs()
} }
function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (Number.isNaN(v)) return val
if (key === "SwitchbackModeCooldown") {
if (v === 0) return "Off"
return v === 1 ? "1 min" : `${v} min`
}
if (key === "DeviceShutdown") {
return v === 1 ? "1 hour" : `${v} hours`
}
const volumeKeys = [
"BelowSteerSpeedVolume", "DisengageVolume", "EngageVolume", "PromptVolume",
"PromptDistractedVolume", "RefuseVolume",
"WarningImmediateVolume", "WarningSoftVolume",
]
if (key && volumeKeys.includes(key)) {
if (v === 0) return "Muted"
if (v === 101) return "Auto"
return `${v}%`
}
if (precisionInt !== undefined && precisionInt !== null) {
return Number(v.toFixed(precisionInt)).toString()
}
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
const dec = stepStr.split(".")[1].length
return Number(v.toFixed(dec)).toString()
}
function formatReadoutValue(p) { function formatReadoutValue(p) {
const raw = state.values[p.key] const raw = state.values[p.key]
const v = parseFloat(raw) const v = parseFloat(raw)
@@ -472,7 +505,6 @@ function formatStepValue(step, precision) {
} }
function numericBounds(param) { function numericBounds(param) {
param = resolveVehicleUnitParam(param, state.values)
const defaultBounds = { const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100), max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
@@ -920,7 +952,13 @@ function syncNumericDisplay(param, rawValue) {
const displayEl = document.getElementById(`ds-display-${param.key}`) const displayEl = document.getElementById(`ds-display-${param.key}`)
if (!displayEl) return if (!displayEl) return
displayEl.textContent = formatNumericParamValue(param, rawValue, state.values) const bounds = numericBounds(param)
displayEl.textContent = formatSliderValue(
rawValue,
String(bounds.step),
param.precision,
param.key,
)
} }
async function updateNumericParam(param, numericValue, options = {}) { async function updateNumericParam(param, numericValue, options = {}) {
@@ -1273,9 +1311,10 @@ function matchesFilter(p) {
if (!state.filter) return true if (!state.filter) return true
if (isGroupParam(p)) return false if (isGroupParam(p)) return false
const q = state.filter.toLowerCase() const q = state.filter.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, state.values) const label = String(p.label || "").toLowerCase()
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms] const key = String(p.key || "").toLowerCase()
.some(value => String(value || "").toLowerCase().includes(q)) const description = String(p.description || "").toLowerCase()
return label.includes(q) || key.includes(q) || description.includes(q)
} }
function clearSearchFilter() { function clearSearchFilter() {
@@ -1337,7 +1376,8 @@ function formatFlmValue(param, value) {
if (value === undefined || value === null) return "not set" if (value === undefined || value === null) return "not set"
if (param.data_type === "bool") return value ? "On" : "Off" if (param.data_type === "bool") return value ? "On" : "Off"
if (param.ui_type === "numeric") { if (param.ui_type === "numeric") {
return formatNumericParamValue(param, value, state.values) const bounds = numericBounds(param)
return formatSliderValue(value, String(bounds.step), param.precision, param.key)
} }
return String(value) return String(value)
} }
@@ -1522,8 +1562,6 @@ function renderSettingRow(p) {
return "" return ""
} }
p = resolveVehicleUnitParam(p, state.values)
const isNumeric = p.ui_type === "numeric" const isNumeric = p.ui_type === "numeric"
const isSlider = isNumeric && p.control === "slider" const isSlider = isNumeric && p.control === "slider"
const isText = p.ui_type === "text" const isText = p.ui_type === "text"
@@ -1566,8 +1604,8 @@ function renderSettingRow(p) {
@input="${(event) => previewSliderParam(p, event.currentTarget.value)}" @input="${(event) => previewSliderParam(p, event.currentTarget.value)}"
@change="${(event) => commitSliderParam(p, event.currentTarget.value)}" /> @change="${(event) => commitSliderParam(p, event.currentTarget.value)}" />
<div class="ds-slider-scale"> <div class="ds-slider-scale">
<span>${formatNumericParamValue(p, numericBounds(p).min, state.values)}</span> <span>${formatSliderValue(numericBounds(p).min, String(numericBounds(p).step), p.precision, p.key)}</span>
<span>${formatNumericParamValue(p, numericBounds(p).max, state.values)}</span> <span>${formatSliderValue(numericBounds(p).max, String(numericBounds(p).step), p.precision, p.key)}</span>
</div> </div>
<button <button
class="ds-reset-btn" class="ds-reset-btn"
@@ -1593,10 +1631,10 @@ function renderSettingRow(p) {
const updating = isNumericUpdating(p.key) const updating = isNumericUpdating(p.key)
const defaultNumeric = resolveDefaultNumericValue(p, bounds) const defaultNumeric = resolveDefaultNumericValue(p, bounds)
const defaultLabel = defaultNumeric !== null const defaultLabel = defaultNumeric !== null
? formatNumericParamValue(p, defaultNumeric, state.values) ? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key)
: "N/A" : "N/A"
const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon
const stepLabel = p.key === "DeviceShutdown" ? "1 hour" : `${formatStepValue(bounds.step, precision)}${p.unit || ""}` const stepLabel = p.key === "DeviceShutdown" ? "1 hour" : formatStepValue(bounds.step, precision)
return html` return html`
<div class="ds-stepper"> <div class="ds-stepper">
<button <button
@@ -1604,7 +1642,7 @@ function renderSettingRow(p) {
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}" disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}"
@click="${() => stepNumericParam(p, -1)}">-</button> @click="${() => stepNumericParam(p, -1)}">-</button>
<div class="ds-stepper-meta"> <div class="ds-stepper-meta">
<span>${formatNumericParamValue(p, bounds.min, state.values)} to ${formatNumericParamValue(p, bounds.max, state.values)}</span> <span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
<span class="ds-step-value">Step: ${stepLabel} per click</span> <span class="ds-step-value">Step: ${stepLabel} per click</span>
<span class="ds-default-value">Default: ${defaultLabel}</span> <span class="ds-default-value">Default: ${defaultLabel}</span>
<div class="ds-manual-row"> <div class="ds-manual-row">
@@ -1752,7 +1790,8 @@ function renderSettingRow(p) {
if (isColor) return formatColorDisplayValue(p) if (isColor) return formatColorDisplayValue(p)
if (isReadout) return formatReadoutValue(p) if (isReadout) return formatReadoutValue(p)
const currentValue = state.sliderPreviewValues[p.key] ?? state.values[p.key] const currentValue = state.sliderPreviewValues[p.key] ?? state.values[p.key]
return currentValue !== undefined ? formatNumericParamValue(p, currentValue, state.values) : ".." const bounds = numericBounds(p)
return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".."
}}</span>` : ""} }}</span>` : ""}
</div> </div>
@@ -1818,11 +1857,6 @@ export function DeviceSettings({ params }) {
<div class="ds-wrapper"> <div class="ds-wrapper">
<h2>Toggles</h2> <h2>Toggles</h2>
<div class="ds-unit-note">
<i class="bi bi-speedometer2"></i>
<span>Vehicle-unit speed settings use <strong>${() => vehicleSpeedUnit(state.values)}</strong> and follow the comma's <em>Use Metric System</em> toggle. Each control shows its adjustment step.</span>
</div>
<div class="ds-search-row"> <div class="ds-search-row">
<input <input
class="ds-search" class="ds-search"
@@ -151,11 +151,6 @@
color: var(--color-black); color: var(--color-black);
} }
.ml-button-danger {
background: rgba(224, 85, 119, 0.12);
color: var(--danger-fg);
}
.ml-button:disabled { .ml-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
@@ -12,6 +12,7 @@ const state = reactive({
download: {}, download: {},
models: [], models: [],
summary: {}, summary: {},
manifest: { version: "unknown", shortcomings: [], opportunities: [] },
}) })
let initialized = false let initialized = false
@@ -26,33 +27,31 @@ function modelLabel(modelId) {
return modelById(modelId)?.label || modelId || "not selected" return modelById(modelId)?.label || modelId || "not selected"
} }
function availableModels() { function readyModels() {
return state.models.filter(model => model.modelLabArtifactAvailable) return state.models.filter(model => model.modelLabArtifactAvailable)
} }
function downloadedModels() {
return availableModels().filter(model => model.modelLabArtifactInstalled)
}
function candidateModels(role) { function candidateModels(role) {
const downloaded = downloadedModels() const ready = readyModels()
if (role !== "longitudinal") return downloaded if (role !== "longitudinal") return ready
const lateral = modelById(state.configuration.lateralModel) const lateral = modelById(state.configuration.lateralModel)
if (!lateral) return downloaded if (!lateral) return ready
return downloaded.filter(model => model.value !== lateral.value) return ready.filter(model => model.value !== lateral.value)
} }
function selectionError() { function selectionError() {
if (!state.chestnutReady) return "Connect a firmware-ready Chestnut first."
if (state.isOnroad) return "Park before changing the laboratory pair." if (state.isOnroad) return "Park before changing the laboratory pair."
if (downloadedModels().length < 2) return "Download at least two eGPU variants before composing a pair."
const lateral = modelById(state.configuration.lateralModel) const lateral = modelById(state.configuration.lateralModel)
const longitudinal = modelById(state.configuration.longitudinalModel) const longitudinal = modelById(state.configuration.longitudinalModel)
if (!lateral || !longitudinal) return "Choose two downloaded eGPU variants." if (!lateral || !longitudinal) return "Choose two small models with published Chestnut artifacts."
if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different." if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different."
if (!lateral.modelLabArtifactAvailable || !longitudinal.modelLabArtifactAvailable) {
return "Both models need a precompiled AMD artifact in the manifest."
}
if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) { if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) {
return "Download both eGPU variants first." return "Prepare both precompiled AMD artifacts first."
} }
if (!state.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
} }
@@ -76,14 +75,17 @@ function applyPayload(payload) {
state.download = payload?.download && typeof payload.download === "object" ? payload.download : {} state.download = payload?.download && typeof payload.download === "object" ? payload.download : {}
state.models = Array.isArray(payload?.models) ? payload.models : [] state.models = Array.isArray(payload?.models) ? payload.models : []
state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {} state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {}
state.manifest = payload?.manifest && typeof payload.manifest === "object"
? payload.manifest
: { version: "unknown", shortcomings: [], opportunities: [] }
state.error = String(payload?.configurationError || "") state.error = String(payload?.configurationError || "")
const downloaded = downloadedModels() const ready = readyModels()
if (!downloaded.some(model => model.value === state.configuration.lateralModel)) { if (!modelById(state.configuration.lateralModel) && ready.length > 0) {
state.configuration.lateralModel = downloaded[0]?.value || "" state.configuration.lateralModel = ready[0].value
} }
if (!downloaded.some(model => model.value === state.configuration.longitudinalModel)) { if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) {
state.configuration.longitudinalModel = downloaded.find(model => ( state.configuration.longitudinalModel = ready.find(model => (
model.value !== state.configuration.lateralModel model.value !== state.configuration.lateralModel
))?.value || "" ))?.value || ""
} }
@@ -155,7 +157,7 @@ async function prepareModel(modelId) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId }), body: JSON.stringify({ model: modelId }),
}) })
state.message = String(payload.message || "eGPU variant download queued.") state.message = String(payload.message || "Chestnut artifact download queued.")
await refresh() await refresh()
} catch (error) { } catch (error) {
state.error = error?.message || String(error) state.error = error?.message || String(error)
@@ -164,29 +166,6 @@ async function prepareModel(modelId) {
} }
} }
async function deleteModel(modelId) {
if (state.saving || !modelId) return
const model = modelById(modelId)
if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return
state.saving = true
state.error = ""
state.message = ""
try {
const payload = await requestJson("/api/model-laboratory/artifact", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId }),
})
selectionDirty = false
applyPayload(payload)
state.message = String(payload.message || "eGPU variant deleted.")
} catch (error) {
state.error = error?.message || String(error)
} finally {
state.saving = false
}
}
function bindControls() { function bindControls() {
const lateral = document.getElementById("ml-lateral-model") const lateral = document.getElementById("ml-lateral-model")
const longitudinal = document.getElementById("ml-longitudinal-model") const longitudinal = document.getElementById("ml-longitudinal-model")
@@ -198,11 +177,6 @@ function bindControls() {
button.dataset.bound = "1" button.dataset.bound = "1"
button.addEventListener("click", () => prepareModel(button.dataset.mlDownload)) button.addEventListener("click", () => prepareModel(button.dataset.mlDownload))
}) })
document.querySelectorAll("[data-ml-delete]").forEach(button => {
if (button.dataset.bound === "1") return
button.dataset.bound = "1"
button.addEventListener("click", () => deleteModel(button.dataset.mlDelete))
})
if (lateral) { if (lateral) {
lateral.value = state.configuration.lateralModel lateral.value = state.configuration.lateralModel
@@ -252,15 +226,15 @@ function ensurePolling() {
return return
} }
await refresh() await refresh()
pollHandle = setTimeout(poll, state.download?.model ? 1000 : 5000) pollHandle = setTimeout(poll, 5000)
} }
pollHandle = setTimeout(poll, 5000) pollHandle = setTimeout(poll, 5000)
} }
function renderModel(model) { function renderModel(model) {
const artifactStatus = model.modelLabArtifactInstalled const artifactStatus = model.modelLabArtifactInstalled
? "eGPU variant downloaded" ? "AMD ready"
: "eGPU variant not downloaded" : model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published"
return html` return html`
<div class="ml-model"> <div class="ml-model">
<div> <div>
@@ -274,15 +248,8 @@ function renderModel(model) {
${artifactStatus} ${artifactStatus}
</span> </span>
${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html` ${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}"> <button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad}">
${() => state.download?.model === model.value Prepare for Chestnut
? `Downloading · ${state.download?.progress || "starting…"}`
: "Download eGPU variant"}
</button>
` : ""}
${model.modelLabArtifactInstalled ? html`
<button class="ml-button ml-button-danger" data-ml-delete="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}">
Delete eGPU variant
</button> </button>
` : ""} ` : ""}
</div> </div>
@@ -320,22 +287,11 @@ export function ModelLaboratory() {
${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""} ${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""}
${() => !state.loading ? html` ${() => !state.loading ? html`
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.</p>
<p>${() => `${state.summary.ready || 0} downloaded · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => availableModels().map(renderModel)}</div>
</section>
<section class="ml-card"> <section class="ml-card">
<div class="ml-card-heading"> <div class="ml-card-heading">
<div> <div>
<h3>Compose a pair</h3> <h3>Compose a pair</h3>
<p>Choose from downloaded eGPU variant combinations below.</p> <p>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p>
</div> </div>
<span class="${() => `ml-state ${state.configuration.enabled ? "is-enabled" : ""}`}"> <span class="${() => `ml-state ${state.configuration.enabled ? "is-enabled" : ""}`}">
${() => state.configuration.enabled ? "Enabled" : "Disabled"} ${() => state.configuration.enabled ? "Enabled" : "Disabled"}
@@ -408,6 +364,20 @@ export function ModelLaboratory() {
<p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p> <p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
</section> </section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>${() => `${state.summary.ready || 0} ready to pair · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => readyModels().map(renderModel)}</div>
<div class="ml-note">
Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma.
A normal installed model may still need its separate Chestnut artifact.
</div>
</section>
` : ""} ` : ""}
</div> </div>
` `
@@ -37,44 +37,13 @@
.wheelCard, .wheelCard,
.wheelNotice, .wheelNotice,
.wheelError, .wheelError,
.wheelDeviceSummary, .wheelDeviceSummary {
.wheelPolicy {
background: var(--sidebar-bg); background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color); border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: 16px; padding: 16px;
} }
.wheelPolicy {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
cursor: pointer;
}
.wheelPolicy span,
.wheelPolicy small {
display: block;
}
.wheelPolicy small {
margin-top: 5px;
opacity: 0.68;
}
.wheelPolicy input {
width: 22px;
height: 22px;
flex: 0 0 auto;
accent-color: #8b6cc5;
}
.wheelPolicy:has(input:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
.wheelSlotGrid, .wheelSlotGrid,
.wheelControllerGrid, .wheelControllerGrid,
.wheelMappings { .wheelMappings {
@@ -11,7 +11,6 @@ const state = reactive({
slots: [], slots: [],
controllerSlots: [], controllerSlots: [],
controllerOptions: [], controllerOptions: [],
disconnectControllersOffroad: false,
speedUnit: "mph", speedUnit: "mph",
speedMinimum: 5, speedMinimum: 5,
speedMaximum: 90, speedMaximum: 90,
@@ -38,7 +37,6 @@ async function refresh() {
state.slots = Array.isArray(payload.slots) ? payload.slots : [] state.slots = Array.isArray(payload.slots) ? payload.slots : []
state.controllerSlots = Array.isArray(payload.controller_slots) ? payload.controller_slots : [] state.controllerSlots = Array.isArray(payload.controller_slots) ? payload.controller_slots : []
state.controllerOptions = Array.isArray(payload.controller_options) ? payload.controller_options : [] state.controllerOptions = Array.isArray(payload.controller_options) ? payload.controller_options : []
state.disconnectControllersOffroad = !!payload.disconnect_controllers_offroad
state.speedUnit = typeof payload.speed_unit === "string" ? payload.speed_unit : "mph" state.speedUnit = typeof payload.speed_unit === "string" ? payload.speed_unit : "mph"
state.speedMinimum = Number(payload.speed_minimum || 5) state.speedMinimum = Number(payload.speed_minimum || 5)
state.speedMaximum = Number(payload.speed_maximum || 90) state.speedMaximum = Number(payload.speed_maximum || 90)
@@ -267,16 +265,6 @@ export function WheelControls() {
${() => !state.loading && !state.available && state.mappings.length ? html`<div class="wheelNotice">The wheel control service is starting.</div>` : ""} ${() => !state.loading && !state.available && state.mappings.length ? html`<div class="wheelNotice">The wheel control service is starting.</div>` : ""}
${() => state.testing ? testPanel() : ""} ${() => state.testing ? testPanel() : ""}
<label class="wheelPolicy">
<span>
<strong>Disconnect controllers when offroad</strong>
<small>After two minutes offroad, paired controllers disconnect to save battery and reconnect when the car starts. Bluetooth and audio-only devices stay connected.</small>
</span>
<input type="checkbox" checked="${() => state.disconnectControllersOffroad}"
disabled="${() => !state.offroad || !!state.busy}"
@change="${event => request("offroad-disconnect", { enabled: event.currentTarget.checked })}" />
</label>
<div class="wheelDeviceSummary"> <div class="wheelDeviceSummary">
<div class="wheelDeviceHeading"> <div class="wheelDeviceHeading">
<strong>Connected input devices</strong> <strong>Connected input devices</strong>
@@ -592,24 +592,6 @@ ul { list-style: none; margin: 0; padding: 0; }
transition: transform var(--motion-fast), box-shadow var(--motion-fast); transition: transform var(--motion-fast), box-shadow var(--motion-fast);
} }
.gx-unit-note {
align-items: center;
background: var(--primary-container);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
color: var(--on-primary-container);
display: flex;
font-size: var(--fs-sm);
gap: var(--sp-2);
margin: 0 0 var(--sp-4);
padding: var(--sp-3) var(--sp-4);
}
.gx-unit-note i {
color: var(--primary);
font-size: 1.2rem;
}
[data-theme="light"] .gx-card { [data-theme="light"] .gx-card {
border: 1px solid rgba(120, 73, 232, 0.22); border: 1px solid rgba(120, 73, 232, 0.22);
} }
@@ -767,13 +749,6 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-slider-row .gx-row__value { text-align: left; min-width: 0; } .gx-slider-row .gx-row__value { text-align: left; min-width: 0; }
.gx-slider-row .gx-slider-reset { align-self: flex-end; } .gx-slider-row .gx-slider-reset { align-self: flex-end; }
.gx-slider-meta {
color: var(--text-muted);
display: flex;
font-size: var(--fs-xs);
justify-content: space-between;
}
input[type="range"].gx-slider { input[type="range"].gx-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@@ -1522,4 +1497,4 @@ input[type="color"].gx-color {
.gx-menu-btn { display: inline-flex; } .gx-menu-btn { display: inline-flex; }
.gx-back-btn { display: none; } .gx-back-btn { display: none; }
.gx-content { padding-bottom: var(--sp-6); } .gx-content { padding-bottom: var(--sp-6); }
} }
@@ -136,7 +136,6 @@ export const api = {
getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) }, getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) },
saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) }, saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) },
prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) }, prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) },
deleteModelLabArtifact(model) { return request("/api/model-laboratory/artifact", { method: "DELETE", data: { model } }) },
getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) }, getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) },
getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) }, getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) },
@@ -1,8 +1,8 @@
import { api, showSnackbar } from "../api.js" import { api, showSnackbar } from "../api.js"
import { import {
coerceValueByType, formatNumericParamValue, formatReadoutValue, getColorDefault, coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep, normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
resolveVehicleUnitParam, stepPrecision, stepPrecision,
} from "../params.js" } from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js" import { FavoritesEditor } from "./FavoritesEditor.js"
@@ -12,7 +12,6 @@ export const GalaxyToggleCard = {
props: { props: {
param: { type: Object, required: true }, param: { type: Object, required: true },
value: { default: undefined }, value: { default: undefined },
values: { type: Object, default: () => ({}) },
locked: { type: Boolean, default: false }, locked: { type: Boolean, default: false },
manageable: { type: Boolean, default: false }, manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false }, manageOpen: { type: Boolean, default: false },
@@ -29,9 +28,8 @@ export const GalaxyToggleCard = {
} }
}, },
computed: { computed: {
displayParam() { return resolveVehicleUnitParam(this.param, this.values) }, bounds() { return numericBounds(this.param, {}) },
bounds() { return numericBounds(this.displayParam, this.values) }, precision() { return stepPrecision(this.bounds.step, this.param.precision) },
precision() { return stepPrecision(this.bounds.step, this.displayParam.precision) },
epsilon() { return numericEpsilon(this.precision) }, epsilon() { return numericEpsilon(this.precision) },
isSlider() { return this.isNumeric }, isSlider() { return this.isNumeric },
isNumeric() { return this.param.ui_type === "numeric" }, isNumeric() { return this.param.ui_type === "numeric" },
@@ -40,17 +38,11 @@ export const GalaxyToggleCard = {
currentValue() { return this.preview !== undefined ? this.preview : this.value }, currentValue() { return this.preview !== undefined ? this.preview : this.value },
displayValue() { displayValue() {
if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock" if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
if (this.isReadout) return formatReadoutValue(this.displayParam, this.value) if (this.isReadout) return formatReadoutValue(this.param, this.value)
return this.value !== undefined && this.value !== null ? formatNumericParamValue(this.displayParam, this.value, this.values) : ".." return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
}, },
sliderDisplay() { sliderDisplay() {
return this.value !== undefined ? formatNumericParamValue(this.displayParam, this.currentValue, this.values) : ".." return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
sliderRangeDisplay() {
return `${formatNumericParamValue(this.displayParam, this.bounds.min, this.values)} to ${formatNumericParamValue(this.displayParam, this.bounds.max, this.values)}`
},
sliderStepDisplay() {
return formatNumericParamValue(this.displayParam, this.bounds.step, this.values)
}, },
isColor() { return this.param.ui_type === "color" }, isColor() { return this.param.ui_type === "color" },
isAction() { return this.param.ui_type === "action" }, isAction() { return this.param.ui_type === "action" },
@@ -175,10 +167,10 @@ export const GalaxyToggleCard = {
<div> <div>
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }"> <div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
<div class="gx-row__info"> <div class="gx-row__info">
<span class="gx-row__label">{{ displayParam.label }} <span class="gx-row__label">{{ param.label }}
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span> <span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span> </span>
<span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.description }}</span> <span v-if="param.description" class="gx-row__desc">{{ param.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div> <div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
</div> </div>
@@ -198,10 +190,6 @@ export const GalaxyToggleCard = {
:value="currentValue" :disabled="locked || updating" :value="currentValue" :disabled="locked || updating"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur" @input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" /> @touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<div v-if="displayParam.unit_type" class="gx-slider-meta">
<span>{{ sliderRangeDisplay }}</span>
<span>Step: {{ sliderStepDisplay }}</span>
</div>
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button> <button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
</div> </div>
@@ -1,5 +1,5 @@
import { api } from "../api.js" import { api } from "../api.js"
import { isSettingVisible, resolveVehicleUnitParam, slugifySectionName, applyParamChange } from "../params.js" import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
import { SettingTree } from "./SettingTree.js" import { SettingTree } from "./SettingTree.js"
import { GalaxySection } from "./GalaxySection.js" import { GalaxySection } from "./GalaxySection.js"
@@ -35,9 +35,7 @@ export const ParamSections = {
matches(p) { matches(p) {
if (!this.search) return true if (!this.search) return true
const q = this.search.toLowerCase() const q = this.search.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, this.values) return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms]
.some((v) => String(v || "").toLowerCase().includes(q))
}, },
async load() { async load() {
try { try {
@@ -30,7 +30,7 @@ export const SettingTree = {
template: ` template: `
<template v-for="p in children" :key="p.key"> <template v-for="p in children" :key="p.key">
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth"> <div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)" :manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" /> @change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div> </div>
@@ -12,7 +12,6 @@ export const WheelControls = {
loading: true, busy: "", available: false, offroad: false, learning: false, loading: true, busy: "", available: false, offroad: false, learning: false,
devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [], devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false, joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
disconnectControllersOffroad: false,
lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "", lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
} }
}, },
@@ -30,7 +29,6 @@ export const WheelControls = {
this.slots = Array.isArray(p.slots) ? p.slots : [] this.slots = Array.isArray(p.slots) ? p.slots : []
this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : [] this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : [] this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
this.disconnectControllersOffroad = !!p.disconnect_controllers_offroad
this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : "" this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null
this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0 this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0
@@ -101,18 +99,6 @@ export const WheelControls = {
<button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button> <button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button>
<button type="button" class="gx-btn gx-btn--danger" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button> <button type="button" class="gx-btn gx-btn--danger" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button>
</div> </div>
<div class="gx-row" style="margin-bottom:12px;">
<div class="gx-row__info">
<span class="gx-row__label">Disconnect controllers when offroad</span>
<span class="gx-row__desc">After two minutes offroad, paired controllers disconnect to save battery and reconnect when the car starts. Bluetooth and audio-only devices stay connected.</span>
</div>
<label class="gx-switch">
<input type="checkbox" :checked="disconnectControllersOffroad" :disabled="disabled()"
@change="request('offroad-disconnect', { enabled: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<div v-if="testing && lastTested" style="margin-bottom:12px;"> <div v-if="testing && lastTested" style="margin-bottom:12px;">
<span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span> <span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span>
<p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p> <p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p>
@@ -1,45 +1,4 @@
export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode" export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
export const VEHICLE_SPEED_UNIT_TYPE = "vehicle_speed"
const SPEED_OFFSET_RANGES = {
imperial: ["024", "2534", "3544", "4554", "5564", "6574", "7599"],
metric: ["029", "3049", "5059", "6079", "8099", "100119", "120140"],
}
export function usesMetricUnits(values = {}) {
const value = values?.IsMetric
if (value === true || value === 1) return true
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase())
}
export function vehicleSpeedUnit(values = {}) {
return usesMetricUnits(values) ? "km/h" : "mph"
}
export function resolveVehicleUnitParam(param, values = {}) {
if (!param || param.unit_type !== VEHICLE_SPEED_UNIT_TYPE) return param
const metric = usesMetricUnits(values)
const mode = metric ? "metric" : "imperial"
const resolved = {
...param,
unit: ` ${vehicleSpeedUnit(values)}`,
unit_search_terms: "metric imperial mph km/h vehicle speed units",
}
for (const field of ["min", "max", "step", "precision"]) {
const override = param[`${mode}_${field}`]
if (override !== undefined && override !== null) resolved[field] = override
}
if (Number.isInteger(param.unit_range_index)) {
const range = SPEED_OFFSET_RANGES[mode][param.unit_range_index]
if (range) {
resolved.label = `Speed Offset (${range} ${vehicleSpeedUnit(values)})`
resolved.description = `How much to offset posted speed limits between ${range} ${vehicleSpeedUnit(values)}.`
}
}
return resolved
}
const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"]) const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"]) const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
@@ -129,8 +88,7 @@ export function countAdvancedHiddenByDeveloperMode(layout, values) {
return count return count
} }
export function numericBounds(param, values = {}) { export function numericBounds(param, values) {
param = resolveVehicleUnitParam(param, values)
const defaultBounds = { const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100), max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
@@ -236,13 +194,6 @@ export function formatSliderValue(val, stepStr, precisionInt, key) {
return Number(v.toFixed(dec)).toString() return Number(v.toFixed(dec)).toString()
} }
export function formatNumericParamValue(param, value, values = {}) {
const resolved = resolveVehicleUnitParam(param, values)
const bounds = numericBounds(resolved, values)
const formatted = formatSliderValue(value, String(bounds.step), resolved.precision, resolved.key)
return resolved.unit && formatted !== "--" ? `${formatted}${resolved.unit}` : formatted
}
export function formatReadoutValue(p, value) { export function formatReadoutValue(p, value) {
const raw = value const raw = value
const parsed = parseFloat(raw) const parsed = parseFloat(raw)
@@ -14,17 +14,13 @@ export const ModelLaboratory = {
isOnroad: false, isOnroad: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" }, configuration: { enabled: false, lateralModel: "", longitudinalModel: "" },
runtime: {}, runtime: {},
download: {},
summary: {}, summary: {},
models: [], models: [],
} }
}, },
computed: { computed: {
availableModels() {
return this.models.filter((m) => m && m.modelLabArtifactAvailable)
},
readyModels() { readyModels() {
return this.availableModels.filter((m) => m.modelLabArtifactInstalled) return this.models.filter((m) => m && m.modelLabArtifactAvailable)
}, },
candidates() { candidates() {
const ready = this.readyModels const ready = this.readyModels
@@ -32,16 +28,18 @@ export const ModelLaboratory = {
return ready.filter((m) => !lat || m.value !== lat) return ready.filter((m) => !lat || m.value !== lat)
}, },
selectionError() { selectionError() {
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut first."
if (this.isOnroad) return "Park before changing the laboratory pair." if (this.isOnroad) return "Park before changing the laboratory pair."
if (this.readyModels.length < 2) return "Download at least two eGPU variants before composing a pair."
const lat = this.modelById(this.configuration.lateralModel) const lat = this.modelById(this.configuration.lateralModel)
const lon = this.modelById(this.configuration.longitudinalModel) const lon = this.modelById(this.configuration.longitudinalModel)
if (!lat || !lon) return "Choose two downloaded eGPU variants." if (!lat || !lon) return "Choose two small models with published Chestnut artifacts."
if (lat.value === lon.value) return "Lateral and longitudinal models must be different." if (lat.value === lon.value) return "Lateral and longitudinal models must be different."
if (!lat.modelLabArtifactAvailable || !lon.modelLabArtifactAvailable) {
return "Both models need a precompiled AMD artifact in the manifest."
}
if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) { if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) {
return "Download both eGPU variants first." return "Prepare both precompiled AMD artifacts first."
} }
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
}, },
runtimeState() { runtimeState() {
@@ -50,7 +48,7 @@ export const ModelLaboratory = {
}, },
}, },
created() { created() {
this.poll = usePolling(() => this.refresh(), { interval: 2000 }) this.poll = usePolling(() => this.refresh(), { interval: 5000 })
this.poll.start() this.poll.start()
}, },
beforeUnmount() { beforeUnmount() {
@@ -64,8 +62,9 @@ export const ModelLaboratory = {
return this.modelById(id)?.label || id || "not selected" return this.modelById(id)?.label || id || "not selected"
}, },
artifactStatus(m) { artifactStatus(m) {
if (m.modelLabArtifactInstalled) return { text: "eGPU variant downloaded", good: true } if (m.modelLabArtifactInstalled) return { text: "AMD ready", good: true }
return { text: "eGPU variant not downloaded", good: false } if (m.modelLabArtifactAvailable) return { text: "AMD download needed", good: false }
return { text: "AMD not published", good: false }
}, },
async refresh() { async refresh() {
try { try {
@@ -83,7 +82,6 @@ export const ModelLaboratory = {
this.isOnroad = Boolean(payload.isOnroad) this.isOnroad = Boolean(payload.isOnroad)
this.error = String(payload.configurationError || "") this.error = String(payload.configurationError || "")
this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {} this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {}
this.download = payload.download && typeof payload.download === "object" ? payload.download : {}
this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {} this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {}
this.models = Array.isArray(payload.models) ? payload.models : [] this.models = Array.isArray(payload.models) ? payload.models : []
const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {} const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {}
@@ -97,10 +95,10 @@ export const ModelLaboratory = {
}, },
normalizeSelection() { normalizeSelection() {
const ready = this.readyModels const ready = this.readyModels
if (!ready.some((m) => m.value === this.configuration.lateralModel)) { if (!this.modelById(this.configuration.lateralModel) && ready.length) {
this.configuration.lateralModel = ready[0]?.value || "" this.configuration.lateralModel = ready[0].value
} }
if (!ready.some((m) => m.value === this.configuration.longitudinalModel)) { if (!this.modelById(this.configuration.longitudinalModel) && ready.length > 1) {
const lon = ready.find((m) => m.value !== this.configuration.lateralModel) const lon = ready.find((m) => m.value !== this.configuration.lateralModel)
this.configuration.longitudinalModel = lon?.value || "" this.configuration.longitudinalModel = lon?.value || ""
} }
@@ -145,8 +143,8 @@ export const ModelLaboratory = {
this.message = "" this.message = ""
try { try {
const payload = await api.prepareModelLabArtifact(modelId) const payload = await api.prepareModelLabArtifact(modelId)
this.message = String(payload?.message || "eGPU variant download queued.") this.message = String(payload?.message || "Chestnut artifact download queued.")
showSnackbar("eGPU variant download queued", "info") showSnackbar("Chestnut artifact download queued", "info")
await this.refresh() await this.refresh()
} catch (e) { } catch (e) {
this.error = e?.message || String(e) this.error = e?.message || String(e)
@@ -154,25 +152,6 @@ export const ModelLaboratory = {
this.saving = false this.saving = false
} }
}, },
async deleteModel(modelId) {
if (this.saving || !modelId) return
const model = this.modelById(modelId)
if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return
this.saving = true
this.error = ""
this.message = ""
try {
const payload = await api.deleteModelLabArtifact(modelId)
this.dirty = false
this.applyPayload(payload)
this.message = String(payload?.message || "eGPU variant deleted.")
showSnackbar("eGPU variant deleted", "info")
} catch (e) {
this.error = e?.message || String(e)
} finally {
this.saving = false
}
},
}, },
template: ` template: `
<div class="gx-view"> <div class="gx-view">
@@ -198,41 +177,12 @@ export const ModelLaboratory = {
</div> </div>
</div> </div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} downloaded · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<div style="padding: 0 var(--sp-4) var(--sp-3); color:var(--text-muted); font-size:var(--fs-sm);">
Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.
</div>
<article v-for="m in availableModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="!m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad || !!download.model" @click="prepareModel(m.value)">
{{ download.model === m.value ? 'Downloading · ' + (download.progress || 'starting…') : 'Download eGPU variant' }}
</button>
<button v-else type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="saving || isOnroad || !!download.model" @click="deleteModel(m.value)">
Delete eGPU variant
</button>
</div>
</article>
</div>
<div class="gx-card"> <div class="gx-card">
<div class="gx-section__header"> <div class="gx-section__header">
<i class="bi bi-collection"></i> <i class="bi bi-collection"></i>
<span class="gx-section__title">Compose a pair</span> <span class="gx-section__title">Compose a pair</span>
<span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span> <span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span>
</div> </div>
<div style="padding: 0 var(--sp-4); color:var(--text-muted); font-size:var(--fs-sm);">Choose from downloaded eGPU variant combinations below.</div>
<div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);"> <div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);">
<label style="display:grid; gap:4px;"> <label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Lateral model</strong> <strong style="font-size:var(--fs-sm);">Lateral model</strong>
@@ -284,6 +234,30 @@ export const ModelLaboratory = {
</div> </div>
</div> </div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} ready to pair · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<article v-for="m in readyModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad" @click="prepareModel(m.value)">
Prepare for Chestnut
</button>
</div>
</article>
<div style="padding: var(--sp-3);">
<p class="gx-row__desc" style="margin:0;">Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. A normal installed model may still need its separate Chestnut artifact.</p>
</div>
</div>
</template> </template>
</div> </div>
`, `,
@@ -2,7 +2,7 @@ import { api, showSnackbar } from "../api.js"
import { navigate, store } from "../store.js" import { navigate, store } from "../store.js"
import { import {
applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible, applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible,
resolveVehicleUnitParam, slugifySectionName, vehicleSpeedUnit, slugifySectionName,
} from "../params.js" } from "../params.js"
import { SettingTree } from "../components/SettingTree.js" import { SettingTree } from "../components/SettingTree.js"
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js" import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
@@ -39,7 +39,6 @@ export const Settings = {
return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0] return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0]
}, },
hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) }, hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
speedUnit() { return vehicleSpeedUnit(this.values) },
searchActive() { return !!this.searchTerm }, searchActive() { return !!this.searchTerm },
searchTerm: { searchTerm: {
get() { return store.search }, get() { return store.search },
@@ -83,9 +82,7 @@ export const Settings = {
matchesFilter(p) { matchesFilter(p) {
if (!this.searchTerm) return true if (!this.searchTerm) return true
const q = this.searchTerm.toLowerCase() const q = this.searchTerm.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, this.values) return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms]
.some((v) => String(v || "").toLowerCase().includes(q))
}, },
selectSection(slug) { selectSection(slug) {
if (slug !== this.activeSectionSlug) navigate("/settings/" + slug) if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
@@ -120,11 +117,6 @@ export const Settings = {
<div> <div>
<h2 style="margin-top:0;">Toggles</h2> <h2 style="margin-top:0;">Toggles</h2>
<div class="gx-unit-note">
<i class="bi bi-speedometer2"></i>
<span>Vehicle-unit speed settings use <strong>{{ speedUnit }}</strong> and follow the comma's <em>Use Metric System</em> toggle. Each control shows its adjustment step.</span>
</div>
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" /> <DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
<div v-if="loading" class="gx-loading">Loading configuration...</div> <div v-if="loading" class="gx-loading">Loading configuration...</div>
@@ -140,7 +132,7 @@ export const Settings = {
<template v-for="section in searchResults" :key="section.slug"> <template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false"> <GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<template v-for="p in section.matches" :key="p.key"> <template v-for="p in section.matches" :key="p.key">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
@change="onParamChange" /> @change="onParamChange" />
</template> </template>
</GalaxySection> </GalaxySection>
@@ -1,6 +1,6 @@
{ {
"name": "Galaxy", "name": "Big Dipper",
"short_name": "Galaxy", "short_name": "Big Dipper",
"description": "Control and configure your openpilot device from anywhere.", "description": "Control and configure your openpilot device from anywhere.",
"icons": [ "icons": [
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
@@ -8,6 +8,7 @@
{ "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } { "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
], ],
"id": "46bf2df73deba8e1512c35de", "id": "46bf2df73deba8e1512c35de",
"start_url": "/mobile/",
"scope": "/", "scope": "/",
"background_color": "#06060f", "background_color": "#06060f",
"theme_color": "#8b6cc5", "theme_color": "#8b6cc5",
@@ -30,7 +30,7 @@
<link rel="stylesheet" href="/assets/components/tools/error_logs.css"> <link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/maps.css"> <link rel="stylesheet" href="/assets/components/tools/maps.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css"> <link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-5"> <link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-4">
<link rel="stylesheet" href="/assets/components/tools/plots.css"> <link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css"> <link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css"> <link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
@@ -42,7 +42,7 @@
<link rel="stylesheet" href="/assets/components/tools/update_manager.css"> <link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1"> <link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-6"> <link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-6">
<link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-3"> <link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-2">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css"> <link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/sentry.css"> <link rel="stylesheet" href="/assets/components/tools/sentry.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css"> <link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
@@ -51,7 +51,7 @@
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css"> <link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module"> <script type="module">
import("/assets/components/router.js?v=router-cycle-fix-8").catch((err) => { import("/assets/components/router.js?v=router-cycle-fix-7").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err); console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body; const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre"); const pre = document.createElement("pre");
@@ -1859,16 +1859,9 @@ def test_model_profiles_can_be_selected_without_external_gpu(monkeypatch, tmp_pa
assert status["activeBigModel"] == "big-one" assert status["activeBigModel"] == "big-one"
assert status["activeSmallModel"] == "small-one" assert status["activeSmallModel"] == "small-one"
monkeypatch.setattr(server, "external_gpu_available", lambda: True)
active_big_response = client.put("/api/models/active", json={"profile": "big", "model": "big-one"})
assert active_big_response.status_code == 200
assert params.values["Model"] == params.values["DrivingModel"] == "big-one"
assert params.values["DrivingModelName"] == "Big One"
disabled = client.put("/api/models/active", json={"profile": "big", "model": ""}) disabled = client.put("/api/models/active", json={"profile": "big", "model": ""})
assert disabled.status_code == 200 assert disabled.status_code == 200
assert params.values["ActiveBigModel"] == "none" assert params.values["ActiveBigModel"] == "none"
assert params.values["Model"] == params.values["DrivingModel"] == "small-one"
assert disabled.get_json()["model"] == "" assert disabled.get_json()["model"] == ""
assert client.get("/api/models/status").get_json()["activeBigModel"] == "" assert client.get("/api/models/status").get_json()["activeBigModel"] == ""
@@ -1906,12 +1899,6 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
"ModelManifestVersion": "v25", "ModelManifestVersion": "v25",
"Model": "rdf43", "Model": "rdf43",
"DrivingModel": "rdf43", "DrivingModel": "rdf43",
"ActiveSmallModel": "rdf43",
"ActiveSmallModelName": "Regret Driven Framework V4",
"ActiveSmallModelVersion": "v15",
"ActiveBigModel": "big",
"ActiveBigModelName": "Chestnut One Billion",
"ActiveBigModelVersion": "v16",
}) })
metadata = { metadata = {
"lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, "lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True,
@@ -1987,16 +1974,10 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
queued = client.post("/api/model-laboratory/download", json={"model": "old"}) queued = client.post("/api/model-laboratory/download", json={"model": "old"})
assert queued.status_code == 200 assert queued.status_code == 200
assert params_memory.values["ModelLabModelToDownload"] == "old" assert params_memory.values["ModelLabModelToDownload"] == "old"
assert "eGPU variant" in params_memory.values["ModelDownloadProgress"] assert "precompiled AMD" in params_memory.values["ModelDownloadProgress"]
params_memory.remove("ModelLabModelToDownload") params_memory.remove("ModelLabModelToDownload")
monkeypatch.setattr(server, "external_gpu_available", lambda: False) monkeypatch.setattr(server, "external_gpu_available", lambda: False)
(tmp_path / "old_driving_chestnut_tinygrad.pkl").unlink(missing_ok=True)
queued_without_chestnut = client.post("/api/model-laboratory/download", json={"model": "old"})
assert queued_without_chestnut.status_code == 200
assert params_memory.values["ModelLabModelToDownload"] == "old"
params_memory.remove("ModelLabModelToDownload")
no_chestnut = client.put("/api/model-laboratory", json={ no_chestnut = client.put("/api/model-laboratory", json={
"enabled": True, "enabled": True,
"lateralModel": "lat", "lateralModel": "lat",
@@ -2005,21 +1986,6 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
assert no_chestnut.status_code == 409 assert no_chestnut.status_code == 409
assert "Chestnut" in no_chestnut.get_json()["error"] assert "Chestnut" in no_chestnut.get_json()["error"]
monkeypatch.setattr(server, "external_gpu_available", lambda: True)
disabled = client.put("/api/model-laboratory", json={
"enabled": False,
"lateralModel": "lat",
"longitudinalModel": "long",
})
assert disabled.status_code == 200
assert params.values["Model"] == params.values["DrivingModel"] == "big"
assert params.values["DrivingModelName"] == "Chestnut One Billion"
deleted = client.delete("/api/model-laboratory/artifact", json={"model": "lat"})
assert deleted.status_code == 200
assert not (tmp_path / "lat_driving_chestnut_tinygrad.pkl").exists()
assert (tmp_path / "lat_driving_tinygrad.pkl").exists()
params.values["IsOnroad"] = True params.values["IsOnroad"] = True
onroad = client.put("/api/model-laboratory", json={"enabled": False}) onroad = client.put("/api/model-laboratory", json={"enabled": False})
assert onroad.status_code == 403 assert onroad.status_code == 403
@@ -46,18 +46,6 @@ def test_device_settings_uses_the_params_api_and_layout_json():
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
def test_device_settings_speed_units_follow_the_vehicle():
source = _device_settings()
assert 'from "/assets/mobile/js/params.js"' in source
assert "resolveVehicleUnitParam" in source
assert "formatNumericParamValue" in source
assert "vehicleSpeedUnit(state.values)" in source
assert "unit_search_terms" in source
assert "Use Metric System" in source
assert "per click" in source
def test_lane_center_offset_can_step_below_zero(): def test_lane_center_offset_can_step_below_zero():
source = _device_settings() source = _device_settings()
@@ -107,41 +107,6 @@ def test_device_shutdown_uses_literal_hours():
assert device_shutdown["step"] == 1 assert device_shutdown["step"] == 1
def test_speed_settings_follow_vehicle_units_with_one_unit_steps():
sections = _params_by_section(_layout())
speed_keys = {
"MinimumLaneChangeSpeed", "PauseLateralSpeed",
"CESpeed", "CESpeedLead", "CESignalSpeed",
"CustomCruise", "CustomCruiseLong", "SetSpeedOffset", "PulseGlideSpeedDelta",
"Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7",
"CCMSpeed", "CCMSpeedLead", "CCMSetSpeedMargin",
"VisionSpeedLimitLowLimitThreshold", "TurnSteeringLimitMuteSpeed",
}
params = {
param["key"]: param
for section in sections.values()
for param in section.values()
if param["key"] in speed_keys
}
assert params.keys() == speed_keys
assert all(param["unit_type"] == "vehicle_speed" for param in params.values())
one_unit_keys = speed_keys - {"PulseGlideSpeedDelta", "VisionSpeedLimitLowLimitThreshold"}
assert all(params[key]["step"] == 1 for key in one_unit_keys)
assert params["PulseGlideSpeedDelta"]["step"] == 0.5
assert params["VisionSpeedLimitLowLimitThreshold"]["step"] == 5
for index in range(7):
offset = params[f"Offset{index + 1}"]
assert offset["unit_range_index"] == index
assert (offset["metric_min"], offset["metric_max"]) == (-150, 150)
assert params["CustomCruise"]["metric_max"] == 150
assert params["CCMSetSpeedMargin"]["metric_max"] == 30
assert params["PulseGlideSpeedDelta"]["imperial_max"] == 15
def test_curve_speed_controller_no_lead_toggle_is_nested_under_csc(): def test_curve_speed_controller_no_lead_toggle_is_nested_under_csc():
csc_no_lead = _params_by_section(_layout())["Longitudinal (Speed & Following)"]["CurveSpeedControllerNoLead"] csc_no_lead = _params_by_section(_layout())["Longitudinal (Speed & Following)"]["CurveSpeedControllerNoLead"]
@@ -7,7 +7,6 @@ ROUTER_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/router.
INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html" INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html"
BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js" BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js"
CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js" CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js"
MOBILE_CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js"
SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js" SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js"
MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js" MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js"
@@ -24,7 +23,7 @@ def test_router_and_settings_cache_bust_is_consistent():
index = INDEX_PATH.read_text(encoding="utf-8") index = INDEX_PATH.read_text(encoding="utf-8")
assert "/assets/components/settings.js?v=router-cycle-fix-5" in router assert "/assets/components/settings.js?v=router-cycle-fix-5" in router
assert "/assets/components/router.js?v=router-cycle-fix-8" in index assert "/assets/components/router.js?v=router-cycle-fix-7" in index
def test_bluetooth_actions_use_reactive_disabled_bindings(): def test_bluetooth_actions_use_reactive_disabled_bindings():
@@ -77,16 +76,6 @@ def test_controller_joystick_mode_requires_explicit_device_selection():
assert 'request("joystick", { device_id: device.device_id, enabled: !selected() })' in source assert 'request("joystick", { device_id: device.device_id, enabled: !selected() })' in source
def test_controller_offroad_disconnect_is_opt_in():
source = CONTROLLERS_PATH.read_text(encoding="utf-8")
mobile_source = MOBILE_CONTROLLERS_PATH.read_text(encoding="utf-8")
for frontend in (source, mobile_source):
assert "Disconnect controllers when offroad" in frontend
assert "After two minutes offroad" in frontend
assert "offroad-disconnect" in frontend
def test_controller_page_has_ten_controller_only_action_slots(): def test_controller_page_has_ten_controller_only_action_slots():
source = CONTROLLERS_PATH.read_text(encoding="utf-8") source = CONTROLLERS_PATH.read_text(encoding="utf-8")
@@ -128,13 +117,8 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy():
assert 'if (!state.chestnutReady)' in source assert 'if (!state.chestnutReady)' in source
assert 'if (state.isOnroad)' in source assert 'if (state.isOnroad)' in source
assert "model.modelLabArtifactInstalled" in source assert "model.modelLabArtifactInstalled" in source
assert "Download eGPU-compatible small models" in source assert "Nothing is compiled on the comma" in source
assert "Choose from downloaded eGPU variant combinations below" in source assert "run every camera frame on Chestnut's AMD GPU" in source
assert "Download eGPU variant" in source
assert "Delete eGPU variant" in source
assert "Nothing is compiled on the comma" not in source
assert "availableModels().filter(model => model.modelLabArtifactInstalled)" in source
assert source.index("<h3>Available models</h3>") < source.index("<h3>Compose a pair</h3>")
assert 'lateral.value === longitudinal.value' in source assert 'lateral.value === longitudinal.value' in source
assert 'lateral.version !== longitudinal.version' not in source assert 'lateral.version !== longitudinal.version' not in source
assert 'class="ml-chip ${' not in source assert 'class="ml-chip ${' not in source
@@ -151,5 +135,5 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy():
assert "longitudinalModel: selectionDirty" in source assert "longitudinalModel: selectionDirty" in source
assert source.count("selectionDirty = true") == 2 assert source.count("selectionDirty = true") == 2
assert "selectionDirty = false\n applyPayload(payload)" in source assert "selectionDirty = false\n applyPayload(payload)" in source
assert 'model_laboratory.js?v=model-lab-6' in ROUTER_PATH.read_text(encoding="utf-8") assert 'model_laboratory.js?v=model-lab-5' in ROUTER_PATH.read_text(encoding="utf-8")
assert 'model_laboratory.css?v=model-lab-5' in INDEX_PATH.read_text(encoding="utf-8") assert 'model_laboratory.css?v=model-lab-4' in INDEX_PATH.read_text(encoding="utf-8")
@@ -231,25 +231,6 @@ def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
"__starpilot_controller_action__:disengage_openpilot", "__starpilot_controller_action__:disengage_openpilot",
} }
assert response.get_json()["speed_unit"] == "mph" assert response.get_json()["speed_unit"] == "mph"
assert response.get_json()["disconnect_controllers_offroad"] is False
def test_wheel_controls_configures_offroad_controller_disconnect(monkeypatch):
client, fake_params = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
response = client.post("/api/wheel-controls/offroad-disconnect", json={"enabled": True})
assert response.status_code == 200
assert fake_params.get_bool("BluetoothDisconnectControllersOffroad")
def test_wheel_controls_offroad_controller_disconnect_requires_offroad(monkeypatch):
client, fake_params = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
response = client.post("/api/wheel-controls/offroad-disconnect", json={"enabled": True})
assert response.status_code == 409
assert not fake_params.get_bool("BluetoothDisconnectControllersOffroad")
def test_wheel_controls_configures_a_controller_only_action(monkeypatch): def test_wheel_controls_configures_a_controller_only_action(monkeypatch):
@@ -62,26 +62,6 @@ def test_slug_middleware_service_worker_and_headers(client):
assert response_direct.headers.get("Service-Worker-Allowed") == "/" assert response_direct.headers.get("Service-Worker-Allowed") == "/"
def test_mobile_manifest_embeds_device_slug_for_fresh_app_login(client):
galaxy_dir = the_galaxy._get_galaxy_dir()
galaxy_dir.mkdir(parents=True, exist_ok=True)
(galaxy_dir / "glxyslug").write_text("df70390ca648d7c3")
response = client.get("/assets/mobile/manifest.json")
assert response.status_code == 200
assert response.mimetype == "application/manifest+json"
assert response.get_json()["start_url"] == "https://galaxy.firestar.link/df70390ca648d7c3"
assert "no-store" in response.headers.get("Cache-Control", "")
def test_mobile_manifest_falls_back_to_local_mobile_route_without_slug(client):
response = client.get("/assets/mobile/manifest.json")
assert response.status_code == 200
assert response.get_json()["start_url"] == "/mobile/"
def test_404_api_returns_json_not_html(client): def test_404_api_returns_json_not_html(client):
# Non-existent API route without slug # Non-existent API route without slug
res1 = client.get("/api/nonexistent") res1 = client.get("/api/nonexistent")
@@ -190,23 +190,6 @@ def test_ui_numeric_toggles_are_sliders_with_default():
assert 'title="Set to zero"' not in card assert 'title="Set to zero"' not in card
def test_ui_speed_units_follow_the_vehicle():
params = _read("js/params.js")
card = _read("js/components/GalaxyToggleCard.js")
tree = _read("js/components/SettingTree.js")
settings = _read("js/views/Settings.js")
assert "resolveVehicleUnitParam" in params
assert "formatNumericParamValue" in params
assert "unit_search_terms" in params and "unit_search_terms" in settings
assert "IsMetric" in params
assert ':values="values"' in tree
assert "displayParam" in card and "formatNumericParamValue" in card
assert "sliderStepDisplay" in card and "Step:" in card
assert ':values="values"' in settings
assert "Use Metric System" in settings
def test_ui_centralizes_api_and_uses_composables(): def test_ui_centralizes_api_and_uses_composables():
api = _read("js/api.js") api = _read("js/api.js")
composables = _read("js/composables.js") composables = _read("js/composables.js")
@@ -360,7 +343,7 @@ def test_ui_manifest_is_valid_pwa_manifest():
assert manifest["display"] == "standalone" assert manifest["display"] == "standalone"
assert manifest["name"] assert manifest["name"]
assert manifest["icons"] assert manifest["icons"]
assert "start_url" not in manifest assert manifest["start_url"] == "/mobile/"
def test_ui_ported_classic_tools_native_no_embed(): def test_ui_ported_classic_tools_native_no_embed():
@@ -522,23 +505,6 @@ assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 } const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap") assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format") assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
const speed = {
key: "Offset2", data_type: "float", unit_type: "vehicle_speed",
min: -99, max: 99, step: 1, precision: 0,
metric_min: -150, metric_max: 150, unit_range_index: 1,
}
const imperialSpeed = P.resolveVehicleUnitParam(speed, { IsMetric: false })
assert(imperialSpeed.unit === " mph", "imperial unit")
assert(imperialSpeed.label === "Speed Offset (2534 mph)", "imperial offset band")
assert(P.formatNumericParamValue(speed, 3, { IsMetric: false }) === "3 mph", "imperial value")
const metricSpeed = P.resolveVehicleUnitParam(speed, { IsMetric: true })
assert(metricSpeed.unit === " km/h", "metric unit")
assert(metricSpeed.label === "Speed Offset (3049 km/h)", "metric offset band")
assert(metricSpeed.unit_search_terms.includes("metric"), "metric settings are searchable")
assert(P.numericBounds(speed, { IsMetric: true }).max === 150, "metric bounds")
assert(P.numericBounds(speed, { IsMetric: true }).step === 1, "one km/h per step")
assert(P.formatNumericParamValue(speed, 3, { IsMetric: true }) === "3 km/h", "metric value")
assert(P.usesMetricUnits({ IsMetric: "1" }) === true, "serialized metric bool")
const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 } const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 }
const laneBounds = P.numericBounds(laneOffset, {}) const laneBounds = P.numericBounds(laneOffset, {})
assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound") assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound")
+22 -86
View File
@@ -5015,7 +5015,6 @@ def setup(app):
"/assets/components/settings.js", "/assets/components/settings.js",
"/assets/components/home/home.js", "/assets/components/home/home.js",
"/assets/components/home/home.css", "/assets/components/home/home.css",
"/assets/mobile/js/params.js",
"/assets/components/tools/device_settings.js", "/assets/components/tools/device_settings.js",
"/assets/components/tools/device_settings.css", "/assets/components/tools/device_settings.css",
"/assets/components/tools/device_settings_layout.json", "/assets/components/tools/device_settings_layout.json",
@@ -5182,7 +5181,6 @@ def setup(app):
status["slots"] = slots status["slots"] = slots
status["controller_slots"] = controller_slots status["controller_slots"] = controller_slots
status["controller_options"] = controller_options status["controller_options"] = controller_options
status["disconnect_controllers_offroad"] = params.get_bool("BluetoothDisconnectControllersOffroad")
is_metric = params.get_bool("IsMetric") is_metric = params.get_bool("IsMetric")
speed_minimum, speed_maximum = controller_speed_bounds(is_metric) speed_minimum, speed_maximum = controller_speed_bounds(is_metric)
status["speed_unit"] = "km/h" if is_metric else "mph" status["speed_unit"] = "km/h" if is_metric else "mph"
@@ -5192,16 +5190,13 @@ def setup(app):
@app.route("/api/wheel-controls/<operation>", methods=["POST"]) @app.route("/api/wheel-controls/<operation>", methods=["POST"])
def wheel_controls_operation(operation): def wheel_controls_operation(operation):
if operation not in {"action", "learn", "cancel", "delete", "clear", "test", "test-stop", "joystick", "offroad-disconnect"}: if operation not in {"action", "learn", "cancel", "delete", "clear", "test", "test-stop", "joystick"}:
return jsonify({"error": "Unknown wheel control operation."}), 404 return jsonify({"error": "Unknown wheel control operation."}), 404
if not params.get_bool("IsOffroad"): if not params.get_bool("IsOffroad"):
return jsonify({"error": "Wheel controls can only be configured offroad."}), 409 return jsonify({"error": "Wheel controls can only be configured offroad."}), 409
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
try: try:
if operation == "offroad-disconnect":
params.put_bool("BluetoothDisconnectControllersOffroad", bool(data.get("enabled", False)))
return jsonify({"message": "Offroad controller disconnect updated."}), 200
if operation == "action": if operation == "action":
slot_index = int(data.get("slot", -1)) slot_index = int(data.get("slot", -1))
key = str(data.get("key") or "").strip() key = str(data.get("key") or "").strip()
@@ -5301,27 +5296,6 @@ def setup(app):
return "Settings catalog not found", 404 return "Settings catalog not found", 404
return send_file(str(SETTINGS_CATALOG_PATH), mimetype="application/json") return send_file(str(SETTINGS_CATALOG_PATH), mimetype="application/json")
@app.route("/assets/mobile/manifest.json", methods=["GET"])
def mobile_manifest():
manifest_path = Path(app.static_folder) / "mobile" / "manifest.json"
if not manifest_path.is_file():
return jsonify({"error": "Big Dipper manifest not found"}), 404
try:
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return jsonify({"error": "Big Dipper manifest is invalid"}), 500
slug = _read_galaxy_text(_get_galaxy_dir() / "glxyslug")
if re.fullmatch(r"[A-Za-z0-9]{16}", slug):
manifest_data["start_url"] = f"https://galaxy.firestar.link/{slug}"
else:
manifest_data["start_url"] = "/mobile/"
response = jsonify(manifest_data)
response.mimetype = "application/manifest+json"
return _no_store_response(response)
@app.route("/manifest.json", methods=["GET"]) @app.route("/manifest.json", methods=["GET"])
@app.route("/assets/manifest.json", methods=["GET"]) @app.route("/assets/manifest.json", methods=["GET"])
def manifest(): def manifest():
@@ -6348,24 +6322,21 @@ def setup(app):
}, },
"manifest": { "manifest": {
"version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown", "version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown",
"shortcomings": [
"The current manifest does not consistently declare model size; legacy non-Chestnut entries are treated as small.",
"The current manifest does not publish AMD-compiled variants for its ordinary small-model downloads.",
"The current manifest does not declare lateral or longitudinal quality/capability tags.",
"The current manifest does not declare output-contract compatibility, memory, or frame-time measurements.",
],
"opportunities": [
"Publish model_size and model_lab_eligible for every model.",
"Publish an accelerator_artifacts.chestnut entry pointing to a precompiled AMD pickle for each supported small model.",
"Publish role scores and pairing notes from replay evaluations.",
"Publish architecture, output-contract, peak-memory, and p50/p95 execution metadata.",
],
}, },
} }
def _activate_preferred_model_profile():
"""Restore the model that the normal small/big profile system would run."""
profile = "big" if external_gpu_available() and _active_model_key("big") else "small"
model_key, model_name, model_version = get_model_profile(params, profile)
if not model_key:
model_key, model_name, model_version = _default_model_key(), _default_model_name(), _default_model_version()
params.put("Model", model_key)
params.put("DrivingModel", model_key)
params.put("DrivingModelName", model_name or model_key)
if model_version:
params.put("ModelVersion", model_version)
params.put("DrivingModelVersion", model_version)
return model_name or model_key
@app.route("/api/model-laboratory", methods=["GET", "PUT"]) @app.route("/api/model-laboratory", methods=["GET", "PUT"])
def model_laboratory(): def model_laboratory():
if request.method == "GET": if request.method == "GET":
@@ -6404,8 +6375,7 @@ def setup(app):
params.put("DrivingModelVersion", lateral["version"]) params.put("DrivingModelVersion", lateral["version"])
message = "Model Laboratory enabled. The pair will load on the next drive." message = "Model Laboratory enabled. The pair will load on the next drive."
else: else:
restored_model = _activate_preferred_model_profile() message = "Model Laboratory disabled."
message = f"Model Laboratory disabled. {restored_model} will be used next."
return jsonify({"message": message, **_model_lab_status_payload()}), 200 return jsonify({"message": message, **_model_lab_status_payload()}), 200
@@ -6413,6 +6383,8 @@ def setup(app):
def download_model_laboratory_artifact(): def download_model_laboratory_artifact():
if params.get_bool("IsOnroad"): if params.get_bool("IsOnroad"):
return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403 return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403
if not external_gpu_available():
return jsonify({"error": "Chestnut is not connected and firmware-ready."}), 409
if ( if (
params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
@@ -6426,50 +6398,16 @@ def setup(app):
if model is None: if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404 return jsonify({"error": f"Unknown model '{model_key}'."}), 404
if not model.get("modelLabEligible"): if not model.get("modelLabEligible"):
return jsonify({"error": "Only compatible small models have Model Laboratory eGPU variants."}), 409 return jsonify({"error": "Only compatible small models can be prepared for Model Laboratory."}), 409
if not model.get("modelLabArtifactAvailable"): if not model.get("modelLabArtifactAvailable"):
return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409 return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409
if model.get("modelLabArtifactInstalled"): if model.get("modelLabArtifactInstalled"):
return jsonify({"message": f"The eGPU variant for \"{model['label']}\" is already downloaded."}), 200 return jsonify({"message": f"\"{model['label']}\" is already prepared for Chestnut."}), 200
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key) params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key)
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Starting eGPU variant download...") params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading precompiled AMD artifact...")
return jsonify({"message": f"Started downloading the eGPU variant for \"{model['label']}\"."}), 200 return jsonify({"message": f"Started preparing \"{model['label']}\" for Chestnut."}), 200
@app.route("/api/model-laboratory/artifact", methods=["DELETE"])
def delete_model_laboratory_artifact():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Model Laboratory eGPU variants can only be deleted while parked."}), 403
if (
params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "")
):
return jsonify({"error": "Cannot delete an eGPU variant while a model download is in progress."}), 409
data = request.get_json(silent=True) or {}
model_key = canonical_model_key(str(data.get("model") or "").strip())
model = next((entry for entry in get_model_catalog() if entry["value"] == model_key), None)
if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
if not model.get("modelLabArtifactInstalled"):
return jsonify({"message": f"No eGPU variant is downloaded for \"{model['label']}\"."}), 200
config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "")
if config["enabled"] and model_key in (config["lateralModel"], config["longitudinalModel"]):
return jsonify({"error": "Disable Model Laboratory or choose a different pair before deleting this eGPU variant."}), 409
artifact_path = MODELS_PATH / model_accelerator_artifact_filename(model_key)
try:
artifact_path.unlink(missing_ok=True)
Path(get_manifest_path(artifact_path)).unlink(missing_ok=True)
for chunk_path in artifact_path.parent.glob(f"{artifact_path.name}.chunk*of*"):
chunk_path.unlink(missing_ok=True)
except Exception as exception:
return jsonify({"error": f"Failed deleting the eGPU variant: {exception}"}), 500
return jsonify({"message": f"Deleted the eGPU variant for \"{model['label']}\".", **_model_lab_status_payload()}), 200
@app.route("/api/models/preferences", methods=["GET", "PUT"]) @app.route("/api/models/preferences", methods=["GET", "PUT"])
def get_or_set_models_preferences(): def get_or_set_models_preferences():
@@ -6523,9 +6461,8 @@ def setup(app):
params.remove(MODEL_LAB_RUNTIME_PARAM) params.remove(MODEL_LAB_RUNTIME_PARAM)
disable_big_model_profile(params) disable_big_model_profile(params)
restored_model = _activate_preferred_model_profile()
return jsonify({ return jsonify({
"message": f"Active Big disabled. {restored_model} will be used even when Chestnut is connected.", "message": "Active Big disabled. Active Small will be used even when Chestnut is connected.",
"profile": profile, "profile": profile,
"model": "", "model": "",
}), 200 }), 200
@@ -6547,9 +6484,8 @@ def setup(app):
params.remove(MODEL_LAB_RUNTIME_PARAM) params.remove(MODEL_LAB_RUNTIME_PARAM)
set_model_profile(params, profile, model_key, model["label"], model["version"]) set_model_profile(params, profile, model_key, model["label"], model["version"])
active_model = _activate_preferred_model_profile()
return jsonify({ return jsonify({
"message": f"Active {profile.title()} set to '{model['label']}'. {active_model} will be used next.", "message": f"Active {profile.title()} set to '{model['label']}'.",
"profile": profile, "profile": profile,
"model": model_key, "model": model_key,
}), 200 }), 200
+8 -5
View File
@@ -7,9 +7,12 @@ from openpilot.common.swaglog import cloudlog
from openpilot.common.pid import PIDController from openpilot.common.pid import PIDController
from openpilot.system.hardware import HARDWARE from openpilot.system.hardware import HARDWARE
# raise fan setpoint on tici/tizi to reduce noise # comma 3/3X (tici/tizi) run a more aggressive, cooler-targeting curve than comma 4 (mici)
# after raising LMH threshold in AGNOS 18.1 to prevent CPU throttling IS_MICI = HARDWARE.get_device_type() == "mici"
OFFSET = 0 if HARDWARE.get_device_type() == "mici" else 5 OFFSET = 0 if IS_MICI else -5
K_P = 0 if IS_MICI else 1.0
FF_LOW = 60.0 if IS_MICI else 55.0
FF_HIGH = 100.0 if IS_MICI else 80.0
class BaseFanController(ABC): class BaseFanController(ABC):
@abstractmethod @abstractmethod
@@ -23,7 +26,7 @@ class TiciFanController(BaseFanController):
cloudlog.info("Setting up TICI fan handler") cloudlog.info("Setting up TICI fan handler")
self.last_ignition = False self.last_ignition = False
self.controller = PIDController(k_p=0, k_i=4e-3, rate=(1 / DT_HW)) self.controller = PIDController(k_p=K_P, k_i=4e-3, rate=(1 / DT_HW))
def update(self, cur_temp: float, ignition: bool) -> int: def update(self, cur_temp: float, ignition: bool) -> int:
self.controller.pos_limit = 100 if ignition else 30 self.controller.pos_limit = 100 if ignition else 30
@@ -35,7 +38,7 @@ class TiciFanController(BaseFanController):
error = cur_temp - (75 + OFFSET) error = cur_temp - (75 + OFFSET)
fan_pwr_out = int(self.controller.update( fan_pwr_out = int(self.controller.update(
error=error, error=error,
feedforward=np.interp(cur_temp, [60.0 + OFFSET, 100.0 + OFFSET], [0, 100]) feedforward=np.interp(cur_temp, [FF_LOW, FF_HIGH], [0, 100])
)) ))
self.last_ignition = ignition self.last_ignition = ignition