Compare commits

...

9 Commits

Author SHA1 Message Date
firestar5683 1c35e376e9 uno mas lil dip 2026-09-08 21:15:28 -05:00
firestar5683 ca3d8a3816 Make external GPU CPU pinning conditional 2026-09-08 18:48:40 -05:00
firestar5683 2360ff9b0f build 2026-09-08 18:19:54 -05:00
firestar5683 0976fd804d The Final Countdown 2026-09-08 18:19:19 -05:00
firestar5683 2504441a4e build 2026-09-08 10:57:22 -05:00
firestar5683 0b5ccb31e1 The Rice Cake 2026-09-08 10:52:51 -05:00
firestar5683 b91ea3e1da Update manifest.json 2026-09-07 22:27:28 -05:00
firestar5683 1588f7041a App 2026-09-07 22:09:45 -05:00
firestar5683 bcf152e6f7 Sleppy time 2026-09-07 21:57:32 -05:00
87 changed files with 1696 additions and 518 deletions
Binary file not shown.
+4 -2
View File
@@ -18,6 +18,7 @@ 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}},
@@ -316,7 +317,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DeveloperSidebarMetric7", {PERSISTENT, INT, "7", "0", 3}}, {"DeveloperSidebarMetric7", {PERSISTENT, INT, "7", "0", 3}},
{"DeveloperUI", {PERSISTENT, BOOL, "0", "0", 3}}, {"DeveloperUI", {PERSISTENT, BOOL, "0", "0", 3}},
{"GalaxyDeveloperMode", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"GalaxyDeveloperMode", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
{"GalaxyMobileDefault", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_ADVANCED}}, {"GalaxyMobileDefault", {PERSISTENT | DONT_LOG, BOOL, "1", "1", 0, SETTINGS_SIMPLE}},
{"DeveloperWidgets", {PERSISTENT, BOOL, "1", "0", 3}}, {"DeveloperWidgets", {PERSISTENT, BOOL, "1", "0", 3}},
{"DeviceManagement", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}}, {"DeviceManagement", {PERSISTENT, BOOL, "1", "0", 1, SETTINGS_SIMPLE}},
{"DeviceShutdown", {PERSISTENT, INT, "6", "6", 1, SETTINGS_SIMPLE}}, {"DeviceShutdown", {PERSISTENT, INT, "6", "6", 1, SETTINGS_SIMPLE}},
@@ -463,7 +464,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, "1", "0", 3}}, {"LeadInfo", {PERSISTENT, BOOL, "0", "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}},
@@ -608,6 +609,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"RelaxedJerkDeceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}}, {"RelaxedJerkDeceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkSpeed", {PERSISTENT, FLOAT, "100.0", "100.0", 3}}, {"RelaxedJerkSpeed", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkSpeedDecrease", {PERSISTENT, FLOAT, "100.0", "100.0", 3}}, {"RelaxedJerkSpeedDecrease", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"ReverseCruise", {PERSISTENT, BOOL, "0", "0", 1, SETTINGS_SIMPLE}},
{"RivianAngleControl", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"RivianAngleControl", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"RivianAngleSaturated", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL, "0", "0"}}, {"RivianAngleSaturated", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL, "0", "0"}},
{"RivianToiRecoveryFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL, "0", "0"}}, {"RivianToiRecoveryFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL, "0", "0"}},
Binary file not shown.
@@ -852,7 +852,6 @@ 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.0667 # GM radar runs at 15Hz instead of the standard 20Hz ret.radarTimeStepDEPRECATED = 0.15 if candidate == CAR.BUICK_LACROSSE else 0.0667
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 = 27 * CV.MPH_TO_MS ret.minSteerSpeed = 28 * 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,6 +205,33 @@ 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,
@@ -291,6 +318,14 @@ 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,7 +860,9 @@ 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_longitudinal_active = longitudinal_active if self.CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else self.CP.openpilotLongitudinalControl lfa_status_cars = (CAR.HYUNDAI_IONIQ_6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN)
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 \
@@ -890,7 +892,8 @@ 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 and angle_lkas_alt and ( forward_stock_lkas = (self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR or
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,10 +2484,11 @@ 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 = False CP.openpilotLongitudinalControl = True
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 = {
@@ -2529,7 +2530,6 @@ 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,13 +2537,12 @@ 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 == [("LKAS", can_bus.ACAN)] assert steering_names == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)]
controller.frame = 1 controller.frame = 1
cc.longActive = True cc.longActive = True
@@ -2708,7 +2707,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_keeps_inactive_status_in_drive(self, standstill): def test_sportage_angle_lkas_alt_forwards_stock_status_when_inactive(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 |
@@ -2716,60 +2715,16 @@ 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=stock_lkas, cs = SimpleNamespace(stock_lfa_msg=None, stock_lkas_msg={},
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)
lkas_msgs = [msg for msg in msgs if msg[0] == 0x110] assert not [msg for msg in msgs if msg[0] in (0x110, 0x12A)]
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: if CP.flags & SubaruFlags.LKAS_ANGLE and CP.carFingerprint != CAR.SUBARU_OUTBACK_2023:
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 == CAR.SUBARU_ASCENT_2023: if self.CP.carFingerprint in (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_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): if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_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 not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS) assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_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,8 +622,9 @@ 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
def test_ascent_angle_controller_uses_fixed_angle_rate_limits(): @pytest.mark.parametrize("platform", (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023))
CP = CarInterface.get_non_essential_params(CAR.SUBARU_ASCENT_2023) def test_angle_controller_uses_fixed_angle_rate_limits(platform):
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,6 +417,18 @@ 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
+6 -3
View File
@@ -284,19 +284,22 @@ ensure_host_python_extensions() {
} }
sync_host_generated_headers() { sync_host_generated_headers() {
if ! command -v capnpc >/dev/null 2>&1; then local capnpc="${ROOT_DIR}/.venv/bin/capnpc"
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 c++:cereal/gen/cpp/ -o "${capnpc_cpp}:cereal/gen/cpp/"
) )
} }
+7 -6
View File
@@ -69,10 +69,6 @@ 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
@@ -88,9 +84,14 @@ def wait_for_external_gpu() -> None:
def external_gpu_compile_command(command: list[str]) -> list[str]: def external_gpu_compile_command(command: list[str]) -> list[str]:
"""Pin USB-GPU compilation to AGNOS' isolated CPU without changing host builds.""" """Pin USB-GPU compilation when AGNOS exposes the isolated CPU."""
if sys.platform == "linux" and platform.machine() == "aarch64": if sys.platform == "linux" and platform.machine() == "aarch64":
return ["taskset", "-c", "7", *command] try:
available_cpus = os.sched_getaffinity(0)
if 7 in available_cpus:
return ["taskset", "-c", "7", *command]
except (AttributeError, OSError):
pass
return command return command
+1 -1
View File
@@ -214,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.gm_cc_only or self.redneck_non_pcm) remembered_resume = resume_prev_button and self._uses_software_cruise()
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
+35 -25
View File
@@ -313,6 +313,22 @@ 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
@@ -483,39 +499,33 @@ 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)
@pytest.mark.parametrize("openpilot_longitudinal", [False, True]) @pytest.mark.parametrize("openpilot_longitudinal", [False, True])
def test_pcm_cruise_uses_pcm_speed(self, openpilot_longitudinal): def test_pcm_cruise_always_tracks_pcm_speed(self, openpilot_longitudinal):
CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=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_cluster_speed_kph = 71.0
helper.initialize_v_cruise(car.CarState(vEgo=40 * CV.KPH_TO_MS), False, False, toggles) helper.initialize_v_cruise(car.CarState(vEgo=40 * CV.KPH_TO_MS), False, False, toggles)
assert not helper.v_cruise_initialized assert not helper.v_cruise_initialized
cs = car.CarState( samples = (
cruiseState={ (72.0, 71.0, None),
"available": True, (25.0, 25.0, {"type": ButtonType.decelCruise, "pressed": True}),
"speed": pcm_speed_kph * CV.KPH_TO_MS, (65.0, 65.0, {"type": ButtonType.decelCruise, "pressed": False}),
"speedCluster": pcm_cluster_speed_kph * CV.KPH_TO_MS, (90.0, 90.0, {"type": ButtonType.accelCruise, "pressed": True}),
}, (5.0, 5.0, {"type": ButtonType.accelCruise, "pressed": False}),
) )
for pcm_speed_kph, pcm_cluster_speed_kph, button_event in samples:
helper.update_v_cruise(cs, True, True, False, toggles) cs = car.CarState(
assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph) cruiseState={
assert helper.v_cruise_cluster_kph == pytest.approx(pcm_cluster_speed_kph) "available": True,
"speed": pcm_speed_kph * CV.KPH_TO_MS,
next_pcm_speed_kph = 74.0 "speedCluster": pcm_cluster_speed_kph * CV.KPH_TO_MS,
next_cs = car.CarState( },
cruiseState={ buttonEvents=[] if button_event is None else [button_event],
"available": True, )
"speed": next_pcm_speed_kph * CV.KPH_TO_MS, helper.update_v_cruise(cs, True, True, False, toggles)
"speedCluster": next_pcm_speed_kph * CV.KPH_TO_MS, assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph)
}, assert helper.v_cruise_cluster_kph == pytest.approx(pcm_cluster_speed_kph)
buttonEvents=[{"type": ButtonType.accelCruise, "pressed": False}],
)
helper.update_v_cruise(next_cs, True, True, False, toggles)
assert helper.v_cruise_kph == pytest.approx(next_pcm_speed_kph)
class TestVCruiseHelperRedneck: class TestVCruiseHelperRedneck:
@@ -639,6 +639,9 @@ class LatControlTorque(LatControl):
output_torque *= tucson_4th_gen_center_taper output_torque *= tucson_4th_gen_center_taper
elif genesis_g70_active: elif genesis_g70_active:
output_torque *= genesis_g70_center_output_taper output_torque *= genesis_g70_center_output_taper
output_torque *= get_genesis_g70_high_speed_transition_scale(
setpoint, desired_lateral_jerk, CS.vEgo,
)
output_torque *= get_genesis_g70_curve_unwind_output_scale(setpoint, desired_lateral_jerk, CS.vEgo) output_torque *= get_genesis_g70_curve_unwind_output_scale(setpoint, desired_lateral_jerk, CS.vEgo)
output_torque *= get_genesis_g70_high_speed_error_scale( output_torque *= get_genesis_g70_high_speed_error_scale(
setpoint, measurement, desired_lateral_jerk, CS.vEgo, setpoint, measurement, desired_lateral_jerk, CS.vEgo,
@@ -275,20 +275,27 @@ 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.16 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.26
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.25 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.75
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.25 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.30
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.22 GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.30
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
GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 3.0 GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 3.0
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_MAX = 0.18
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_SPEED = 45.0 * CV.MPH_TO_MS
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_LAT = 0.45
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_LAT_WIDTH = 0.15
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_JERK = 0.35
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_JERK_WIDTH = 0.15
GENESIS_G70_LOW_SPEED_CENTER_TAPER_MAX = 0.06 GENESIS_G70_LOW_SPEED_CENTER_TAPER_MAX = 0.06
GENESIS_G70_LOW_SPEED_CENTER_TAPER_LAT = 0.14 GENESIS_G70_LOW_SPEED_CENTER_TAPER_LAT = 0.14
GENESIS_G70_LOW_SPEED_CENTER_TAPER_LAT_WIDTH = 0.05 GENESIS_G70_LOW_SPEED_CENTER_TAPER_LAT_WIDTH = 0.05
@@ -307,7 +314,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_BOOST = 0.00 GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX = 0.08
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
@@ -3246,6 +3253,18 @@ def get_genesis_g70_center_output_scale(desired_lateral_accel: float, v_ego: flo
return 1.0 - reduction return 1.0 - reduction
def get_genesis_g70_high_speed_transition_scale(desired_lateral_accel: float,
desired_lateral_jerk: float, v_ego: float) -> float:
speed_weight = _sigmoid((v_ego - GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_SPEED) /
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_SPEED_WIDTH)
center_weight = _sigmoid((GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_LAT - abs(desired_lateral_accel)) /
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_LAT_WIDTH)
jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_JERK) /
GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_JERK_WIDTH)
reduction = (GENESIS_G70_HIGH_SPEED_TRANSITION_DAMPING_MAX * speed_weight * center_weight * jerk_weight)
return 1.0 - reduction
def get_genesis_g70_low_speed_angle_damping(desired_angle_deg: float, actual_angle_deg: float, def get_genesis_g70_low_speed_angle_damping(desired_angle_deg: float, actual_angle_deg: float,
current_output_torque: float, v_ego: float) -> float: current_output_torque: float, v_ego: float) -> float:
angle_error = desired_angle_deg - actual_angle_deg angle_error = desired_angle_deg - actual_angle_deg
@@ -3294,7 +3313,8 @@ 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)
return 1.0 + GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST * speed_weight * lateral_weight * jerk_weight reduction = (GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX * 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,6 +56,10 @@ 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]
@@ -169,7 +173,21 @@ 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):
"""Release a stale hard lead brake once the stop target has eased.""" """Shape low-speed stop braking without overriding urgent targets."""
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,6 +397,27 @@ 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
@@ -433,6 +454,11 @@ 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
@@ -3069,6 +3095,28 @@ 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
+8 -1
View File
@@ -7,6 +7,7 @@ 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
@@ -42,6 +43,11 @@ 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
@@ -636,8 +642,9 @@ 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_valid=['starpilotPlan']) ignore_avg_freq=ignore_avg_freq, 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)
+8 -1
View File
@@ -54,6 +54,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes import (
get_rav4_tss2_pid_output, get_rav4_tss2_pid_output,
get_subaru_impreza_pid_output_scale, get_subaru_impreza_pid_output_scale,
get_genesis_gv70_low_speed_center_overshoot_scale, get_genesis_gv70_low_speed_center_overshoot_scale,
get_genesis_g70_high_speed_transition_scale,
normalize_flm_overrides, normalize_flm_overrides,
set_flm_runtime_overrides, set_flm_runtime_overrides,
) )
@@ -960,7 +961,13 @@ 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 get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) == pytest.approx(1.0) assert get_genesis_g70_high_speed_transition_scale(0.0, 0.8, 65.0 * 0.44704) < \
get_genesis_g70_high_speed_transition_scale(0.0, 0.1, 65.0 * 0.44704)
assert get_genesis_g70_high_speed_transition_scale(1.0, 0.8, 65.0 * 0.44704) > \
get_genesis_g70_high_speed_transition_scale(0.0, 0.8, 65.0 * 0.44704)
assert get_genesis_g70_high_speed_transition_scale(0.0, 0.8, 20.0 * 0.44704) > \
get_genesis_g70_high_speed_transition_scale(0.0, 0.8, 65.0 * 0.44704)
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) == 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,6 +14,7 @@ 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,
) )
@@ -96,6 +97,15 @@ 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,6 +765,15 @@ 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,7 +18,13 @@ 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 LongitudinalPlanner, get_coast_accel, get_vehicle_min_accel, should_publish_planner_fcw from openpilot.selfdrive.controls.lib.longitudinal_planner import (
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,
@@ -310,6 +316,24 @@ 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)
+1 -21
View File
@@ -32,6 +32,7 @@ class DeveloperLayout(Widget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._params = Params() self._params = Params()
self._params.put_bool("LongitudinalManeuverMode", False)
# Build items and keep references for callbacks/state updates # Build items and keep references for callbacks/state updates
self._adb_toggle = toggle_item( self._adb_toggle = toggle_item(
@@ -59,13 +60,6 @@ class DeveloperLayout(Widget):
enabled=ui_state.is_offroad, enabled=ui_state.is_offroad,
) )
self._long_maneuver_toggle = toggle_item(
lambda: tr("Longitudinal Maneuver Mode"),
description="",
initial_state=self._params.get_bool("LongitudinalManeuverMode"),
callback=self._on_long_maneuver_mode,
)
self._alpha_long_toggle = toggle_item( self._alpha_long_toggle = toggle_item(
lambda: tr("openpilot Longitudinal Control (Alpha)"), lambda: tr("openpilot Longitudinal Control (Alpha)"),
description=lambda: tr(DESCRIPTIONS["alpha_longitudinal"]), description=lambda: tr(DESCRIPTIONS["alpha_longitudinal"]),
@@ -87,7 +81,6 @@ class DeveloperLayout(Widget):
self._ssh_toggle, self._ssh_toggle,
self._ssh_keys, self._ssh_keys,
self._joystick_toggle, self._joystick_toggle,
self._long_maneuver_toggle,
self._alpha_long_toggle, self._alpha_long_toggle,
self._ui_debug_toggle, self._ui_debug_toggle,
], line_separator=True, spacing=0) ], line_separator=True, spacing=0)
@@ -114,13 +107,7 @@ class DeveloperLayout(Widget):
else: else:
self._alpha_long_toggle.set_visible(True) self._alpha_long_toggle.set_visible(True)
long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
self._long_maneuver_toggle.action_item.set_enabled(long_man_enabled)
if not long_man_enabled:
self._long_maneuver_toggle.action_item.set_state(False)
self._params.put_bool("LongitudinalManeuverMode", False)
else: else:
self._long_maneuver_toggle.action_item.set_enabled(False)
self._alpha_long_toggle.set_visible(False) self._alpha_long_toggle.set_visible(False)
# TODO: make a param control list item so we don't need to manage internal state as much here # TODO: make a param control list item so we don't need to manage internal state as much here
@@ -129,7 +116,6 @@ class DeveloperLayout(Widget):
("AdbEnabled", self._adb_toggle), ("AdbEnabled", self._adb_toggle),
("SshEnabled", self._ssh_toggle), ("SshEnabled", self._ssh_toggle),
("JoystickDebugMode", self._joystick_toggle), ("JoystickDebugMode", self._joystick_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle),
("ShowDebugInfo", self._ui_debug_toggle), ("ShowDebugInfo", self._ui_debug_toggle),
): ):
@@ -149,12 +135,6 @@ class DeveloperLayout(Widget):
def _on_joystick_debug_mode(self, state: bool): def _on_joystick_debug_mode(self, state: bool):
self._params.put_bool("JoystickDebugMode", state) self._params.put_bool("JoystickDebugMode", state)
self._params.put_bool("LongitudinalManeuverMode", False) self._params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.action_item.set_state(False)
def _on_long_maneuver_mode(self, state: bool):
self._params.put_bool("LongitudinalManeuverMode", state)
self._params.put_bool("JoystickDebugMode", False)
self._joystick_toggle.action_item.set_state(False)
def _on_alpha_long_enabled(self, state: bool): def _on_alpha_long_enabled(self, state: bool):
if state: if state:
@@ -731,7 +731,7 @@ class StarPilotLongitudinalLayout(_SettingsPage):
unit=self._speed_unit(), unit=self._speed_unit(),
value_type="float", value_type="float",
current_value=max(1, self._params.get_float("CustomCruise"))), current_value=max(1, self._params.get_float("CustomCruise"))),
visible=lambda: self._params.get_bool("QOLLongitudinal")), visible=lambda: self._params.get_bool("QOLLongitudinal") and not starpilot_state.car_state.isToyota),
SettingRow("CustomCruiseLong", "value", tr_noop("Cruise Long"), SettingRow("CustomCruiseLong", "value", tr_noop("Cruise Long"),
subtitle="", subtitle="",
get_value=lambda: f"{max(1, self._params.get_float('CustomCruiseLong')):g}{self._speed_unit()}", get_value=lambda: f"{max(1, self._params.get_float('CustomCruiseLong')):g}{self._speed_unit()}",
@@ -739,7 +739,12 @@ class StarPilotLongitudinalLayout(_SettingsPage):
unit=self._speed_unit(), unit=self._speed_unit(),
value_type="float", value_type="float",
current_value=max(1, self._params.get_float("CustomCruiseLong"))), current_value=max(1, self._params.get_float("CustomCruiseLong"))),
visible=lambda: self._params.get_bool("QOLLongitudinal")), visible=lambda: self._params.get_bool("QOLLongitudinal") and not starpilot_state.car_state.isToyota),
SettingRow("ReverseCruise", "toggle", tr_noop("Reverse Cruise Increase"),
subtitle=tr_noop("Swap Toyota/Lexus cruise increments: short press changes the dash set speed by 5; hold changes it by 1."),
get_state=lambda: self._params.get_bool("ReverseCruise"),
set_state=lambda s: self._params.put_bool("ReverseCruise", s),
visible=lambda: self._params.get_bool("QOLLongitudinal") and starpilot_state.car_state.isToyota),
SettingRow("ForceStops", "toggle", tr_noop("Force Stops"), SettingRow("ForceStops", "toggle", tr_noop("Force Stops"),
subtitle="", subtitle="",
get_state=lambda: self._params.get_bool("ForceStops"), get_state=lambda: self._params.get_bool("ForceStops"),
@@ -12,6 +12,7 @@ class DeveloperLayoutMici(NavScroller):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._ssh_fetcher = SshKeyFetcher(ui_state.params) self._ssh_fetcher = SshKeyFetcher(ui_state.params)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
def github_username_callback(username: str): def github_username_callback(username: str):
if username: if username:
@@ -45,7 +46,6 @@ class DeveloperLayoutMici(NavScroller):
self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh)
self._ssh_keys_btn.set_click_callback(ssh_keys_callback) self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
# adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
# ******** Main Scroller ******** # ******** Main Scroller ********
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12))
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12))
@@ -53,9 +53,6 @@ class DeveloperLayoutMici(NavScroller):
self._joystick_toggle = BigToggle("joystick debug mode", self._joystick_toggle = BigToggle("joystick debug mode",
initial_state=ui_state.params.get_bool("JoystickDebugMode"), initial_state=ui_state.params.get_bool("JoystickDebugMode"),
toggle_callback=self._on_joystick_debug_mode) toggle_callback=self._on_joystick_debug_mode)
self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode",
initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
toggle_callback=self._on_long_maneuver_mode)
self._alpha_long_toggle = BigToggle("alpha longitudinal", self._alpha_long_toggle = BigToggle("alpha longitudinal",
initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
toggle_callback=self._on_alpha_long_enabled) toggle_callback=self._on_alpha_long_enabled)
@@ -69,7 +66,6 @@ class DeveloperLayoutMici(NavScroller):
self._ssh_keys_btn, self._ssh_keys_btn,
self._disable_wide_road_toggle, self._disable_wide_road_toggle,
self._joystick_toggle, self._joystick_toggle,
self._long_maneuver_toggle,
self._alpha_long_toggle, self._alpha_long_toggle,
self._debug_mode_toggle, self._debug_mode_toggle,
]) ])
@@ -80,7 +76,6 @@ class DeveloperLayoutMici(NavScroller):
("SshEnabled", self._ssh_toggle), ("SshEnabled", self._ssh_toggle),
("DisableWideRoad", self._disable_wide_road_toggle), ("DisableWideRoad", self._disable_wide_road_toggle),
("JoystickDebugMode", self._joystick_toggle), ("JoystickDebugMode", self._joystick_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle),
("ShowDebugInfo", self._debug_mode_toggle), ("ShowDebugInfo", self._debug_mode_toggle),
) )
@@ -89,7 +84,7 @@ class DeveloperLayoutMici(NavScroller):
self._disable_wide_road_toggle, self._disable_wide_road_toggle,
self._joystick_toggle, self._joystick_toggle,
) )
engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle) engaged_blocked_toggles = (self._alpha_long_toggle,)
# Disable toggles that require offroad # Disable toggles that require offroad
for item in onroad_blocked_toggles: for item in onroad_blocked_toggles:
@@ -129,13 +124,7 @@ class DeveloperLayoutMici(NavScroller):
else: else:
self._alpha_long_toggle.set_visible(True) self._alpha_long_toggle.set_visible(True)
long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
self._long_maneuver_toggle.set_enabled(long_man_enabled)
if not long_man_enabled:
self._long_maneuver_toggle.set_checked(False)
ui_state.params.put_bool("LongitudinalManeuverMode", False)
else: else:
self._long_maneuver_toggle.set_enabled(False)
self._alpha_long_toggle.set_visible(False) self._alpha_long_toggle.set_visible(False)
# Refresh toggles from params to mirror external changes # Refresh toggles from params to mirror external changes
@@ -145,16 +134,8 @@ class DeveloperLayoutMici(NavScroller):
def _on_joystick_debug_mode(self, state: bool): def _on_joystick_debug_mode(self, state: bool):
ui_state.params.put_bool("JoystickDebugMode", state) ui_state.params.put_bool("JoystickDebugMode", state)
ui_state.params.put_bool("LongitudinalManeuverMode", False) ui_state.params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.set_checked(False)
ui_state.params.put_bool("LateralManeuverMode", False) ui_state.params.put_bool("LateralManeuverMode", False)
def _on_long_maneuver_mode(self, state: bool):
ui_state.params.put_bool("LongitudinalManeuverMode", state)
ui_state.params.put_bool("JoystickDebugMode", False)
self._joystick_toggle.set_checked(False)
ui_state.params.put_bool("LateralManeuverMode", False)
restart_needed_callback(state)
def _on_alpha_long_enabled(self, state: bool): def _on_alpha_long_enabled(self, state: bool):
# TODO: show confirmation dialog before enabling # TODO: show confirmation dialog before enabling
ui_state.params.put_bool("AlphaLongitudinalEnabled", state) ui_state.params.put_bool("AlphaLongitudinalEnabled", state)
@@ -63,6 +63,7 @@ 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")
@@ -76,6 +77,7 @@ 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,
@@ -93,6 +95,7 @@ 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)
+35 -3
View File
@@ -13,8 +13,9 @@ 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 from openpilot.system.ui.lib.application import gui_app, FontWeight
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
@@ -66,6 +67,7 @@ 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
@@ -136,6 +138,7 @@ 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
@@ -159,7 +162,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() self._draw_lead_indicator(radar_state)
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)
@@ -498,7 +501,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): def _draw_lead_indicator(self, radar_state):
# 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:
@@ -508,6 +511,35 @@ 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"""
+44 -3
View File
@@ -1,19 +1,25 @@
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): def __init__(self, enabled: bool, lead_info: bool = False):
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):
assert key == "HideLeadMarker" if 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):
@@ -31,3 +37,38 @@ 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]
+1 -6
View File
@@ -1022,11 +1022,6 @@ 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...",
@@ -1059,7 +1054,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, "Chestnut artifact downloaded!") self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!")
return True return True
finally: finally:
self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
+13 -2
View File
@@ -326,7 +326,8 @@ 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))
monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True) # These are precompiled files, so downloading must not require a connected eGPU.
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 = []
@@ -346,7 +347,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] == "Chestnut artifact downloaded!" assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "eGPU variant 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
@@ -404,10 +405,20 @@ def test_external_gpu_compile_uses_agnos_isolated_cpu(monkeypatch):
command = ["python3", "compile_modeld.py"] command = ["python3", "compile_modeld.py"]
monkeypatch.setattr(model_compiler.sys, "platform", "linux") monkeypatch.setattr(model_compiler.sys, "platform", "linux")
monkeypatch.setattr(model_compiler.platform, "machine", lambda: "aarch64") monkeypatch.setattr(model_compiler.platform, "machine", lambda: "aarch64")
monkeypatch.setattr(model_compiler.os, "sched_getaffinity", lambda _: {7}, raising=False)
assert model_compiler.external_gpu_compile_command(command) == ["taskset", "-c", "7", *command] assert model_compiler.external_gpu_compile_command(command) == ["taskset", "-c", "7", *command]
def test_external_gpu_compile_skips_unavailable_agnos_cpu(monkeypatch):
command = ["python3", "compile_modeld.py"]
monkeypatch.setattr(model_compiler.sys, "platform", "linux")
monkeypatch.setattr(model_compiler.platform, "machine", lambda: "aarch64")
monkeypatch.setattr(model_compiler.os, "sched_getaffinity", lambda _: {0, 1, 2, 3}, raising=False)
assert model_compiler.external_gpu_compile_command(command) is command
def test_external_gpu_compile_does_not_pin_other_platforms(monkeypatch): def test_external_gpu_compile_does_not_pin_other_platforms(monkeypatch):
command = ["python3", "compile_modeld.py"] command = ["python3", "compile_modeld.py"]
monkeypatch.setattr(model_compiler.sys, "platform", "darwin") monkeypatch.setattr(model_compiler.sys, "platform", "darwin")
@@ -201,6 +201,8 @@
"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"
}, },
@@ -339,6 +341,8 @@
"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"
}, },
@@ -1016,6 +1020,8 @@
"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"
}, },
@@ -1028,6 +1034,8 @@
"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"
}, },
@@ -1113,6 +1121,8 @@
"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"
}, },
@@ -1690,6 +1700,14 @@
"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,
"excluded_vehicle_makes": [
"Lexus",
"Toyota"
],
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1701,6 +1719,28 @@
"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,
"excluded_vehicle_makes": [
"Lexus",
"Toyota"
],
"parent_key": "QOLLongitudinal",
"settings_tier": "simple"
},
{
"key": "ReverseCruise",
"label": "Reverse Cruise Increase",
"description": "Reverse Toyota/Lexus cruise-button behavior so a short press changes the dashboard set speed by 5 and a hold changes it by 1.",
"picker_description": "Swaps Toyota/Lexus short-press and hold cruise increments.",
"data_type": "bool",
"ui_type": "toggle",
"vehicle_makes": [
"Lexus",
"Toyota"
],
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1774,6 +1814,10 @@
"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"
}, },
@@ -1787,6 +1831,8 @@
"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"
}, },
@@ -2223,6 +2269,12 @@
"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"
}, },
@@ -2234,6 +2286,12 @@
"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"
}, },
@@ -2245,6 +2303,12 @@
"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"
}, },
@@ -2256,6 +2320,12 @@
"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"
}, },
@@ -2267,6 +2337,12 @@
"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"
}, },
@@ -2278,6 +2354,12 @@
"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"
}, },
@@ -2289,6 +2371,12 @@
"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"
}, },
@@ -2341,6 +2429,8 @@
"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"
}, },
@@ -2353,6 +2443,8 @@
"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"
}, },
@@ -2385,6 +2477,8 @@
"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"
}, },
@@ -2454,6 +2548,7 @@
"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"
}, },
@@ -4756,12 +4851,12 @@
}, },
{ {
"key": "GalaxyMobileDefault", "key": "GalaxyMobileDefault",
"label": "Try the Big Dipper Web UI", "label": "Use Galaxy (new) by Default",
"description": "Open the Big Dipper at the top-level Galaxy link instead of the classic Galaxy. The classic UI remains available at /classic and Big Dipper at /mobile regardless of this toggle.", "description": "Open Galaxy (new) at the top-level Galaxy link. Turn this off to use Galaxy (old) instead. Galaxy (old) remains available at /classic and Galaxy (new) at /mobile.",
"picker_description": "Serve the Big Dipper as the default landing page.", "picker_description": "Serve Galaxy (new) as the default landing page.",
"data_type": "bool", "data_type": "bool",
"ui_type": "toggle", "ui_type": "toggle",
"settings_tier": "advanced" "settings_tier": "simple"
}, },
{ {
"key": "AlphaLongitudinalEnabled", "key": "AlphaLongitudinalEnabled",
@@ -4818,6 +4913,7 @@
"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"
}, },
+19 -2
View File
@@ -390,6 +390,16 @@ def speed_limit_controller_available(openpilot_longitudinal: bool, redneck_cruis
return openpilot_longitudinal or redneck_cruise return openpilot_longitudinal or redneck_cruise
def software_cruise_intervals_available(quality_of_life: bool, car_make: str, pcm_cruise: bool,
openpilot_longitudinal: bool, pcm_cruise_speed: bool) -> bool:
return bool(quality_of_life and not (car_make == "toyota" and pcm_cruise) and
(openpilot_longitudinal or not pcm_cruise_speed))
def reverse_cruise_available(quality_of_life: bool, car_make: str, pcm_cruise: bool) -> bool:
return bool(quality_of_life and car_make == "toyota" and pcm_cruise)
def migrate_cancel_button_controls(params: Params | None = None) -> bool: def migrate_cancel_button_controls(params: Params | None = None) -> bool:
params = params or Params(return_defaults=True) params = params or Params(return_defaults=True)
if params.get_bool(CANCEL_BUTTON_MIGRATION_KEY) or not params.get_bool("RemapCancelToDistance"): if params.get_bool(CANCEL_BUTTON_MIGRATION_KEY) or not params.get_bool("RemapCancelToDistance"):
@@ -1345,10 +1355,17 @@ class StarPilotVariables:
toggle.pause_lateral_below_signal = self.get_value("PauseLateralOnSignal", condition=toggle.pause_lateral_below_speed != 0) toggle.pause_lateral_below_signal = self.get_value("PauseLateralOnSignal", condition=toggle.pause_lateral_below_speed != 0)
toggle.pause_lateral_signal_delay = self.get_value("LateralResumeDelay", cast=float, condition=toggle.pause_lateral_below_signal, default=0.0, min=0.0, max=5.0) toggle.pause_lateral_signal_delay = self.get_value("LateralResumeDelay", cast=float, condition=toggle.pause_lateral_below_signal, default=0.0, min=0.0, max=5.0)
quality_of_life_longitudinal = toggle.openpilot_longitudinal and self.get_value("QOLLongitudinal") quality_of_life = self.get_value("QOLLongitudinal")
quality_of_life_cruise = self.get_value("QOLLongitudinal") and (toggle.openpilot_longitudinal or not FPCP.pcmCruiseSpeed) quality_of_life_longitudinal = toggle.openpilot_longitudinal and quality_of_life
quality_of_life_cruise = software_cruise_intervals_available(
quality_of_life, toggle.car_make, pcm_cruise, toggle.openpilot_longitudinal, FPCP.pcmCruiseSpeed,
)
toggle.cruise_increase = self.get_value("CustomCruise", cast=float, condition=quality_of_life_cruise, default=1.0) toggle.cruise_increase = self.get_value("CustomCruise", cast=float, condition=quality_of_life_cruise, default=1.0)
toggle.cruise_increase_long = self.get_value("CustomCruiseLong", cast=float, condition=quality_of_life_cruise, default=5.0) toggle.cruise_increase_long = self.get_value("CustomCruiseLong", cast=float, condition=quality_of_life_cruise, default=5.0)
toggle.reverse_cruise_increase = self.get_value(
"ReverseCruise",
condition=reverse_cruise_available(quality_of_life, toggle.car_make, pcm_cruise),
)
toggle.force_stops = self.get_value("ForceStops", condition=quality_of_life_longitudinal) toggle.force_stops = self.get_value("ForceStops", condition=quality_of_life_longitudinal)
toggle.force_stop_distance_offset = self.get_value("ForceStopDistanceOffset", cast=int, condition=(quality_of_life_longitudinal and toggle.force_stops)) toggle.force_stop_distance_offset = self.get_value("ForceStopDistanceOffset", cast=int, condition=(quality_of_life_longitudinal and toggle.force_stops))
toggle.force_standstill = self.get_value("ForceStandstill", condition=quality_of_life_longitudinal) toggle.force_standstill = self.get_value("ForceStandstill", condition=quality_of_life_longitudinal)
@@ -328,4 +328,14 @@ def test_set_speed_limit_unavailable_on_stock_pcm_without_helper():
def test_speed_limit_controller_available_on_openpilot_longitudinal_or_redneck(): def test_speed_limit_controller_available_on_openpilot_longitudinal_or_redneck():
assert spv.speed_limit_controller_available(openpilot_longitudinal=True, redneck_cruise=False) is True assert spv.speed_limit_controller_available(openpilot_longitudinal=True, redneck_cruise=False) is True
assert spv.speed_limit_controller_available(openpilot_longitudinal=False, redneck_cruise=True) is True assert spv.speed_limit_controller_available(openpilot_longitudinal=False, redneck_cruise=True) is True
def test_toyota_pcm_cruise_uses_hardware_reverse_instead_of_software_intervals():
assert spv.software_cruise_intervals_available(True, "toyota", True, True, True) is False
assert spv.reverse_cruise_available(True, "toyota", True) is True
def test_non_toyota_software_cruise_keeps_custom_intervals():
assert spv.software_cruise_intervals_available(True, "hyundai", False, True, True) is True
assert spv.reverse_cruise_available(True, "hyundai", False) is False
assert spv.speed_limit_controller_available(openpilot_longitudinal=False, redneck_cruise=False) is False assert spv.speed_limit_controller_available(openpilot_longitudinal=False, redneck_cruise=False) is False
+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) -> None: def connect(self, address: str, timeout: float = 30.0) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Connect", timeout=30.0) self._call(device["path"], DEVICE_IFACE, "Connect", timeout=timeout)
def disconnect(self, address: str) -> None: def disconnect(self, address: str) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
+115 -31
View File
@@ -18,8 +18,10 @@ 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:
@@ -36,6 +38,9 @@ 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
@@ -223,6 +228,8 @@ 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)
@@ -234,6 +241,8 @@ 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":
@@ -272,47 +281,122 @@ 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)
if self._pairing_address or now - self._last_reconnect < RECONNECT_INTERVAL_SECONDS: suspend_controller_reconnect = self._maintain_controller_offroad_policy(status, now)
continue self._maintain_reconnects(status, now, suspend_controller_reconnect)
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,6 +55,8 @@ 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",
@@ -91,8 +93,11 @@ 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): def connect(self, address, timeout=30.0):
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))
@@ -297,6 +302,7 @@ 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"]})
@@ -304,6 +310,7 @@ 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():
@@ -432,6 +439,85 @@ 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,14 +3,13 @@ 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-2" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-3"
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"
import { TSKManager } from "/assets/components/tools/tsk_manager.js" import { TSKManager } from "/assets/components/tools/tsk_manager.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js" import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
import { MapsManager } from "/assets/components/tools/maps.js" import { MapsManager } from "/assets/components/tools/maps.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js?v=nav-search-context-2" import { NavDestination } from "/assets/components/navigation/navigation_destination.js?v=nav-search-context-2"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app-keys-session-1" import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app-keys-session-1"
@@ -21,7 +20,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-5" import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-6"
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"
@@ -91,7 +90,6 @@ function Root() {
createRoute("model_laboratory", "/model_laboratory", ModelLaboratory), createRoute("model_laboratory", "/model_laboratory", ModelLaboratory),
createRoute("tuning", "/tuning", Tuning), createRoute("tuning", "/tuning", Tuning),
createRoute("lateral_maneuvers", "/lateral_maneuvers", Tuning), createRoute("lateral_maneuvers", "/lateral_maneuvers", Tuning),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
createRoute("maps", "/manage_maps", MapsManager), createRoute("maps", "/manage_maps", MapsManager),
createRoute("plots", "/plots", LivePlots), createRoute("plots", "/plots", LivePlots),
createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("thememaker", "/theme_maker", ThemeMaker),
@@ -18,7 +18,6 @@ const MENU_ITEMS = {
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" }, { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "Controllers", link: "/wheel-controls", icon: "bi-controller" }, { name: "Controllers", link: "/wheel-controls", icon: "bi-controller" },
{ name: "Lateral Tuning", link: "/tuning", icon: "bi-sign-turn-right" }, { name: "Lateral Tuning", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Maps", link: "/manage_maps", icon: "bi-map" }, { name: "Maps", link: "/manage_maps", icon: "bi-map" },
{ name: "Navigation", link: "/set_navigation_destination", icon: "bi-geo-alt-fill" }, { name: "Navigation", link: "/set_navigation_destination", icon: "bi-geo-alt-fill" },
{ name: "App Keys", link: "/manage_navigation_keys", icon: "bi-key-fill" }, { name: "App Keys", link: "/manage_navigation_keys", icon: "bi-key-fill" },
@@ -342,6 +342,24 @@
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,4 +1,5 @@
import { html, reactive } from "/assets/vendor/arrow-core.js" import { html, reactive } from "/assets/vendor/arrow-core.js"
import { formatNumericParamValue, resolveVehicleUnitParam } from "/assets/mobile/js/params.js"
const endpointOptionsCache = {} const endpointOptionsCache = {}
const endpointOptionsInflight = {} const endpointOptionsInflight = {}
@@ -101,9 +102,11 @@ function normalizeVehicleMake(value) {
function isVehicleSettingVisible(section, param) { function isVehicleSettingVisible(section, param) {
const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null) const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null)
if (!allowedMakes) return true
const selectedMake = normalizeVehicleMake(state.values.CarMake) const selectedMake = normalizeVehicleMake(state.values.CarMake)
return allowedMakes.some(make => normalizeVehicleMake(make) === selectedMake) if (allowedMakes && !allowedMakes.some(make => normalizeVehicleMake(make) === selectedMake)) return false
const excludedMakes = param.excluded_vehicle_makes || []
return !excludedMakes.some(make => normalizeVehicleMake(make) === selectedMake)
} }
function matchesSettingValueCondition(param) { function matchesSettingValueCondition(param) {
@@ -448,40 +451,6 @@ 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)
@@ -505,6 +474,7 @@ 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),
@@ -952,13 +922,7 @@ 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
const bounds = numericBounds(param) displayEl.textContent = formatNumericParamValue(param, rawValue, state.values)
displayEl.textContent = formatSliderValue(
rawValue,
String(bounds.step),
param.precision,
param.key,
)
} }
async function updateNumericParam(param, numericValue, options = {}) { async function updateNumericParam(param, numericValue, options = {}) {
@@ -1311,10 +1275,9 @@ 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 label = String(p.label || "").toLowerCase() const displayParam = resolveVehicleUnitParam(p, state.values)
const key = String(p.key || "").toLowerCase() return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms]
const description = String(p.description || "").toLowerCase() .some(value => String(value || "").toLowerCase().includes(q))
return label.includes(q) || key.includes(q) || description.includes(q)
} }
function clearSearchFilter() { function clearSearchFilter() {
@@ -1376,8 +1339,7 @@ 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") {
const bounds = numericBounds(param) return formatNumericParamValue(param, value, state.values)
return formatSliderValue(value, String(bounds.step), param.precision, param.key)
} }
return String(value) return String(value)
} }
@@ -1562,6 +1524,8 @@ 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"
@@ -1604,8 +1568,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>${formatSliderValue(numericBounds(p).min, String(numericBounds(p).step), p.precision, p.key)}</span> <span>${formatNumericParamValue(p, numericBounds(p).min, state.values)}</span>
<span>${formatSliderValue(numericBounds(p).max, String(numericBounds(p).step), p.precision, p.key)}</span> <span>${formatNumericParamValue(p, numericBounds(p).max, state.values)}</span>
</div> </div>
<button <button
class="ds-reset-btn" class="ds-reset-btn"
@@ -1631,10 +1595,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
? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key) ? formatNumericParamValue(p, defaultNumeric, state.values)
: "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) const stepLabel = p.key === "DeviceShutdown" ? "1 hour" : `${formatStepValue(bounds.step, precision)}${p.unit || ""}`
return html` return html`
<div class="ds-stepper"> <div class="ds-stepper">
<button <button
@@ -1642,7 +1606,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>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span> <span>${formatNumericParamValue(p, bounds.min, state.values)} to ${formatNumericParamValue(p, bounds.max, state.values)}</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">
@@ -1790,8 +1754,7 @@ 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]
const bounds = numericBounds(p) return currentValue !== undefined ? formatNumericParamValue(p, currentValue, state.values) : ".."
return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".."
}}</span>` : ""} }}</span>` : ""}
</div> </div>
@@ -151,6 +151,11 @@
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,7 +12,6 @@ const state = reactive({
download: {}, download: {},
models: [], models: [],
summary: {}, summary: {},
manifest: { version: "unknown", shortcomings: [], opportunities: [] },
}) })
let initialized = false let initialized = false
@@ -27,31 +26,33 @@ function modelLabel(modelId) {
return modelById(modelId)?.label || modelId || "not selected" return modelById(modelId)?.label || modelId || "not selected"
} }
function readyModels() { function availableModels() {
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 ready = readyModels() const downloaded = downloadedModels()
if (role !== "longitudinal") return ready if (role !== "longitudinal") return downloaded
const lateral = modelById(state.configuration.lateralModel) const lateral = modelById(state.configuration.lateralModel)
if (!lateral) return ready if (!lateral) return downloaded
return ready.filter(model => model.value !== lateral.value) return downloaded.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 small models with published Chestnut artifacts." if (!lateral || !longitudinal) return "Choose two downloaded eGPU variants."
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 "Prepare both precompiled AMD artifacts first." return "Download both eGPU variants first."
} }
if (!state.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
} }
@@ -75,17 +76,14 @@ 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 ready = readyModels() const downloaded = downloadedModels()
if (!modelById(state.configuration.lateralModel) && ready.length > 0) { if (!downloaded.some(model => model.value === state.configuration.lateralModel)) {
state.configuration.lateralModel = ready[0].value state.configuration.lateralModel = downloaded[0]?.value || ""
} }
if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) { if (!downloaded.some(model => model.value === state.configuration.longitudinalModel)) {
state.configuration.longitudinalModel = ready.find(model => ( state.configuration.longitudinalModel = downloaded.find(model => (
model.value !== state.configuration.lateralModel model.value !== state.configuration.lateralModel
))?.value || "" ))?.value || ""
} }
@@ -157,7 +155,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 || "Chestnut artifact download queued.") state.message = String(payload.message || "eGPU variant download queued.")
await refresh() await refresh()
} catch (error) { } catch (error) {
state.error = error?.message || String(error) state.error = error?.message || String(error)
@@ -166,6 +164,29 @@ 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")
@@ -177,6 +198,11 @@ 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
@@ -226,15 +252,15 @@ function ensurePolling() {
return return
} }
await refresh() await refresh()
pollHandle = setTimeout(poll, 5000) pollHandle = setTimeout(poll, state.download?.model ? 1000 : 5000)
} }
pollHandle = setTimeout(poll, 5000) pollHandle = setTimeout(poll, 5000)
} }
function renderModel(model) { function renderModel(model) {
const artifactStatus = model.modelLabArtifactInstalled const artifactStatus = model.modelLabArtifactInstalled
? "AMD ready" ? "eGPU variant downloaded"
: model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published" : "eGPU variant not downloaded"
return html` return html`
<div class="ml-model"> <div class="ml-model">
<div> <div>
@@ -248,8 +274,15 @@ 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}"> <button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}">
Prepare for Chestnut ${() => state.download?.model === model.value
? `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>
@@ -287,11 +320,22 @@ 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>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p> <p>Choose from downloaded eGPU variant combinations below.</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"}
@@ -364,20 +408,6 @@ 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,13 +37,44 @@
.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,6 +11,7 @@ const state = reactive({
slots: [], slots: [],
controllerSlots: [], controllerSlots: [],
controllerOptions: [], controllerOptions: [],
disconnectControllersOffroad: false,
speedUnit: "mph", speedUnit: "mph",
speedMinimum: 5, speedMinimum: 5,
speedMaximum: 90, speedMaximum: 90,
@@ -37,6 +38,7 @@ 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)
@@ -265,6 +267,16 @@ 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>
@@ -138,10 +138,13 @@
.dh-donut { .dh-donut {
--dh-value: 0; --dh-value: 0;
display: grid; align-items: center;
display: flex;
flex-direction: column;
flex: 0 0 auto; flex: 0 0 auto;
gap: 3px;
height: 116px; height: 116px;
place-items: center; justify-content: center;
position: relative; position: relative;
width: 116px; width: 116px;
} }
@@ -592,6 +592,66 @@ 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;
}
.gx-row.gx-diagnostic-row--changed {
background: rgba(139, 108, 197, 0.18) !important;
box-shadow: inset 3px 0 var(--primary);
}
.gx-update-progress {
display: grid;
gap: 6px;
margin-top: var(--sp-2);
}
.gx-update-progress__track {
background: rgba(255, 255, 255, 0.12);
border-radius: var(--radius-full);
height: 12px;
overflow: hidden;
}
.gx-update-progress__fill {
background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%);
border-radius: inherit;
height: 100%;
transition: width 0.4s ease;
}
.gx-update-progress__fill--error {
background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%);
}
.gx-update-progress__meta {
align-items: center;
display: flex;
font-size: var(--fs-sm);
gap: var(--sp-2);
justify-content: space-between;
}
.gx-update-progress small {
color: var(--text-muted);
overflow-wrap: anywhere;
}
[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);
} }
@@ -749,6 +809,13 @@ 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;
@@ -1497,4 +1564,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); }
} }
@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Big Dipper"> <meta name="apple-mobile-web-app-title" content="Galaxy">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="theme-color" content="#8b6cc5" /> <meta name="theme-color" content="#8b6cc5" />
<link rel="manifest" href="/assets/mobile/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="/assets/mobile/manifest.json" crossorigin="use-credentials">
@@ -26,7 +26,7 @@
} }
</script> </script>
<title>Big Dipper</title> <title>Galaxy</title>
</head> </head>
<body> <body>
@@ -136,6 +136,7 @@ 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()) },
@@ -8,12 +8,12 @@ import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js" import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js" import { Navigation } from "./views/Navigation.js"
import { Vehicle } from "./views/Vehicle.js" import { Vehicle } from "./views/Vehicle.js"
import { Bluetooth } from "./views/Bluetooth.js"
import { SystemTools } from "./views/SystemTools.js" import { SystemTools } from "./views/SystemTools.js"
import { ToolEmbed } from "./views/ToolEmbed.js" import { ToolEmbed } from "./views/ToolEmbed.js"
import { Doors } from "./views/Doors.js" import { Doors } from "./views/Doors.js"
import { Galaxy } from "./views/Galaxy.js" import { Galaxy } from "./views/Galaxy.js"
import { Tsk } from "./views/Tsk.js" import { Tsk } from "./views/Tsk.js"
import { Sentry } from "./views/Sentry.js"
import { ModelManager } from "./views/ModelManager.js" import { ModelManager } from "./views/ModelManager.js"
import { Plots } from "./views/Plots.js" import { Plots } from "./views/Plots.js"
import { TestingGround } from "./views/TestingGround.js" import { TestingGround } from "./views/TestingGround.js"
@@ -43,12 +43,13 @@ const VIEWS = {
"/tuning": Tuning, "/tuning": Tuning,
"/navigation": Navigation, "/navigation": Navigation,
"/vehicle": Vehicle, "/vehicle": Vehicle,
"/bluetooth": Bluetooth,
"/system": SystemTools, "/system": SystemTools,
"/embed": ToolEmbed, "/embed": ToolEmbed,
"/manage_doors": Doors, "/manage_doors": Doors,
"/galaxy": Galaxy, "/galaxy": Galaxy,
"/manage_tsk": Tsk, "/manage_tsk": Tsk,
"/sentry": Sentry, "/sentry": Cameras,
"/manage_models": ModelManager, "/manage_models": ModelManager,
"/plots": Plots, "/plots": Plots,
"/testing_ground": TestingGround, "/testing_ground": TestingGround,
@@ -7,12 +7,12 @@ const NAV = {
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" }, { name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
], ],
tools: [ tools: [
{ name: "Bluetooth", link: "/bluetooth", icon: "bi-bluetooth" },
{ name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video" }, { name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" }, { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" }, { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" }, { name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" }, { name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
{ name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" }, { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
@@ -102,7 +102,7 @@ export const AppShell = {
<button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true"> <button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true">
<i class="bi bi-list"></i> <i class="bi bi-list"></i>
</button> </button>
<span class="gx-appbar__title">Big Dipper</span> <span class="gx-appbar__title">Galaxy</span>
<div class="gx-searchwrap"> <div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..." <input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..."
v-model="search" aria-label="Search toggles" /> v-model="search" aria-label="Search toggles" />
@@ -128,8 +128,8 @@ export const AppShell = {
</transition> </transition>
<aside class="gx-drawer" :class="{ open: store.drawerOpen }"> <aside class="gx-drawer" :class="{ open: store.drawerOpen }">
<div class="gx-drawer__header"> <div class="gx-drawer__header">
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Big Dipper logo" /> <img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<span class="gx-drawer-title">Big Dipper</span> <span class="gx-drawer-title">Galaxy</span>
</div> </div>
<div class="gx-nav-section"> <div class="gx-nav-section">
<div class="gx-nav-section__title">Main</div> <div class="gx-nav-section__title">Main</div>
@@ -20,6 +20,7 @@ export const BluetoothPanel = {
availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) }, availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) },
}, },
methods: { methods: {
address,
async refresh() { async refresh() {
try { try {
const p = await api.getBluetoothStatus() const p = await api.getBluetoothStatus()
@@ -1,8 +1,8 @@
import { api, showSnackbar } from "../api.js" import { api, showSnackbar } from "../api.js"
import { import {
coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault, coerceValueByType, formatNumericParamValue, formatReadoutValue, getColorDefault,
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep, normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
stepPrecision, resolveVehicleUnitParam, stepPrecision,
} from "../params.js" } from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js" import { FavoritesEditor } from "./FavoritesEditor.js"
@@ -12,6 +12,7 @@ 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 },
@@ -28,8 +29,9 @@ export const GalaxyToggleCard = {
} }
}, },
computed: { computed: {
bounds() { return numericBounds(this.param, {}) }, displayParam() { return resolveVehicleUnitParam(this.param, this.values) },
precision() { return stepPrecision(this.bounds.step, this.param.precision) }, bounds() { return numericBounds(this.displayParam, this.values) },
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" },
@@ -38,11 +40,17 @@ 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.param, this.value) if (this.isReadout) return formatReadoutValue(this.displayParam, this.value)
return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".." return this.value !== undefined && this.value !== null ? formatNumericParamValue(this.displayParam, this.value, this.values) : ".."
}, },
sliderDisplay() { sliderDisplay() {
return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".." return this.value !== undefined ? formatNumericParamValue(this.displayParam, this.currentValue, this.values) : ".."
},
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" },
@@ -167,10 +175,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">{{ param.label }} <span class="gx-row__label">{{ displayParam.label }}
<span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span> <span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span> </span>
<span v-if="param.description" class="gx-row__desc">{{ param.description }}</span> <span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.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>
@@ -190,6 +198,10 @@ 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, slugifySectionName, applyParamChange } from "../params.js" import { isSettingVisible, resolveVehicleUnitParam, 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,7 +35,9 @@ 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()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q)) const displayParam = resolveVehicleUnitParam(p, this.values)
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]" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :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>
@@ -216,6 +216,7 @@ export const TroubleshootPanel = {
<div v-if="!itemsVisible(section).length" class="gx-empty">No settings are currently different from their defaults.</div> <div v-if="!itemsVisible(section).length" class="gx-empty">No settings are currently different from their defaults.</div>
<div v-else style="display:grid; gap:8px; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); padding:0 var(--sp-3) var(--sp-3);"> <div v-else style="display:grid; gap:8px; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); padding:0 var(--sp-3) var(--sp-3);">
<div v-for="item in itemsVisible(section)" :key="item.label" class="gx-row" <div v-for="item in itemsVisible(section)" :key="item.label" class="gx-row"
:class="{ 'gx-diagnostic-row--changed': isChanged(item) }"
style="border:none; background:var(--surface); border-radius:var(--radius-md); margin:0; padding:10px 12px; flex-direction:column; align-items:stretch; gap:8px;"> style="border:none; background:var(--surface); border-radius:var(--radius-md); margin:0; padding:10px 12px; flex-direction:column; align-items:stretch; gap:8px;">
<div style="display:flex; align-items:center; gap:6px; min-width:0;"> <div style="display:flex; align-items:center; gap:6px; min-width:0;">
<span class="gx-row__label" style="font-size:var(--fs-sm); overflow-wrap:anywhere;">{{ item.label }}</span> <span class="gx-row__label" style="font-size:var(--fs-sm); overflow-wrap:anywhere;">{{ item.label }}</span>
@@ -12,6 +12,7 @@ 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: "",
} }
}, },
@@ -29,6 +30,7 @@ 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
@@ -99,6 +101,18 @@ 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,4 +1,45 @@
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"])
@@ -88,7 +129,8 @@ 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),
@@ -194,6 +236,13 @@ 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)
@@ -91,7 +91,7 @@ export function goBack() {
window.location.hash = prev window.location.hash = prev
} }
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed", "/manage_doors", "/galaxy", "/manage_tsk", "/sentry", "/manage_models", "/plots", "/testing_ground", "/theme_maker", "/model_laboratory", "/cameras"]) const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/bluetooth", "/system", "/embed", "/manage_doors", "/galaxy", "/manage_tsk", "/sentry", "/manage_models", "/plots", "/testing_ground", "/theme_maker", "/model_laboratory", "/cameras"])
export function toolHref(link) { export function toolHref(link) {
const path = link.split("?")[0] const path = link.split("?")[0]
@@ -0,0 +1,35 @@
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { WheelControls } from "../components/WheelControls.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js"
const TABS = {
bluetooth: "Bluetooth",
controllers: "Controllers",
}
export const Bluetooth = {
name: "Bluetooth",
components: { BluetoothPanel, WheelControls, GalaxySection, GalaxyTabs },
setup() {
return useTabRouting("/bluetooth", { bluetooth: "bluetooth", controllers: "controllers" })
},
data() { return { TABS } },
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Bluetooth</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'bluetooth'">
<GalaxySection title="Bluetooth Devices" icon="bi-bluetooth" :collapsible="false">
<BluetoothPanel />
</GalaxySection>
</template>
<template v-else>
<WheelControls />
</template>
</div>
`,
}
@@ -1,18 +1,20 @@
import { GalaxyTabs } from "../components/GalaxyTabs.js" import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js" import { useTabRouting } from "../composables.js"
import { Sentry } from "./Sentry.js"
import { Vasm } from "./Vasm.js" import { Vasm } from "./Vasm.js"
import { Pip } from "./Pip.js" import { Pip } from "./Pip.js"
const TABS = { const TABS = {
sentry: "Sentry Mode",
vasm: "V-ASM Spot Monitor", vasm: "V-ASM Spot Monitor",
pip: "PiP Side Camera", pip: "PiP Side Camera",
} }
export const Cameras = { export const Cameras = {
name: "Cameras", name: "Cameras",
components: { Vasm, Pip, GalaxyTabs }, components: { Sentry, Vasm, Pip, GalaxyTabs },
setup() { setup() {
return useTabRouting("/cameras", { vasm: "vasm", pip: "pip" }) return useTabRouting("/cameras", { sentry: "sentry", vasm: "vasm", pip: "pip" })
}, },
data() { return { TABS } }, data() { return { TABS } },
template: ` template: `
@@ -20,7 +22,11 @@ export const Cameras = {
<h2 style="margin-top:0;">Cameras & Monitoring</h2> <h2 style="margin-top:0;">Cameras & Monitoring</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" /> <GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'vasm'"> <template v-if="tab === 'sentry'">
<Sentry />
</template>
<template v-else-if="tab === 'vasm'">
<Vasm :embedded="true" /> <Vasm :embedded="true" />
</template> </template>
@@ -4,6 +4,13 @@ import { PwaInstallSection, isFirestarOrigin } from "../components/PwaInstallSec
const isTunnel = () => isFirestarOrigin() const isTunnel = () => isFirestarOrigin()
function localDeviceUrl(ip, route = "/") {
const raw = String(ip || "").trim()
if (!raw || raw === "unknown") return ""
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
return `http://${host}:8082/#${route}`
}
export const Galaxy = { export const Galaxy = {
name: "Galaxy", name: "Galaxy",
components: { PwaInstallSection }, components: { PwaInstallSection },
@@ -14,10 +21,17 @@ export const Galaxy = {
url: "", url: "",
password: "", password: "",
submitting: false, submitting: false,
localUrl: "",
} }
}, },
async mounted() { async mounted() {
if (this.isTunnel) return if (this.isTunnel) {
try {
const status = await api.getDeviceStatus()
this.localUrl = localDeviceUrl(status?.lanIp, "/galaxy")
} catch (e) {}
return
}
try { try {
const data = await api.getGalaxyStatus() const data = await api.getGalaxyStatus()
this.paired = !!data?.paired this.paired = !!data?.paired
@@ -76,7 +90,14 @@ export const Galaxy = {
<i class="bi bi-satellite gx-alert__icon"></i> <i class="bi bi-satellite gx-alert__icon"></i>
<div class="gx-alert__body"> <div class="gx-alert__body">
<strong>Galaxy Pairing Unavailable via Galaxy</strong> <strong>Galaxy Pairing Unavailable via Galaxy</strong>
<span>Galaxy pairing requires a direct connection. Connect to your device's local network to use this feature.</span> <span>
Galaxy pairing requires a direct connection. If you are on the same local network, connect here:
<br />
<a v-if="localUrl" class="gx-btn gx-btn--tonal" :href="localUrl" style="margin-top:var(--sp-3);">
<i class="bi bi-box-arrow-up-right"></i> Open Galaxy Locally
</a>
<span v-else>your device's local IP on port 8082.</span>
</span>
</div> </div>
</div> </div>
</section> </section>
@@ -253,7 +253,7 @@ export const Home = {
name: m.name, name: m.name,
label: `${toInt(m.drives)} ${toNum(m.drives) === 1 ? "drive" : "drives"} using this model`, label: `${toInt(m.drives)} ${toNum(m.drives) === 1 ? "drive" : "drives"} using this model`,
})) }))
return { hasModels: true, style: `background: conic-gradient(${segments.join(", ")})`, rows } return { hasModels: true, style: `conic-gradient(${segments.join(", ")})`, rows }
}, },
storageView() { storageView() {
@@ -510,7 +510,7 @@ export const Home = {
<section class="gx-card dh-card"> <section class="gx-card dh-card">
<div class="dh-card__head"><i class="bi bi-stars"></i><span>Most used models</span></div> <div class="dh-card__head"><i class="bi bi-stars"></i><span>Most used models</span></div>
<div v-if="modelView.hasModels" class="dh-body dh-models"> <div v-if="modelView.hasModels" class="dh-body dh-models">
<div class="dh-chart-ring" :style="{ background: modelView.style }"></div> <div class="dh-chart-ring" :style="{ backgroundImage: modelView.style }" role="img" aria-label="Model usage share"></div>
<div class="dh-models__list"> <div class="dh-models__list">
<div v-for="m in modelView.rows" :key="m.name" class="dh-model"> <div v-for="m in modelView.rows" :key="m.name" class="dh-model">
<span class="dh-swatch" :style="{ background: m.color }"></span> <span class="dh-swatch" :style="{ background: m.color }"></span>
@@ -5,9 +5,9 @@ import { TroubleshootPanel } from "../components/TroubleshootPanel.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js" import { GalaxyTabs } from "../components/GalaxyTabs.js"
const TABS = { const TABS = {
troubleshoot: "Troubleshoot",
errors: "Error Logs", errors: "Error Logs",
tmux: "Tmux Live Log", tmux: "Tmux Live Log",
troubleshoot: "Troubleshoot",
} }
function parseLogDate(filename) { function parseLogDate(filename) {
@@ -35,7 +35,7 @@ export const Logs = {
} }
}, },
setup() { setup() {
return useTabRouting("/logs", { errors: "errors", tmux: "tmux", troubleshoot: "troubleshoot" }) return useTabRouting("/logs", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux" })
}, },
created() { created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 }) this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
@@ -14,32 +14,34 @@ 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: {
readyModels() { availableModels() {
return this.models.filter((m) => m && m.modelLabArtifactAvailable) return this.models.filter((m) => m && m.modelLabArtifactAvailable)
}, },
readyModels() {
return this.availableModels.filter((m) => m.modelLabArtifactInstalled)
},
candidates() { candidates() {
const ready = this.readyModels const ready = this.readyModels
const lat = this.configuration.lateralModel const lat = this.configuration.lateralModel
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 small models with published Chestnut artifacts." if (!lat || !lon) return "Choose two downloaded eGPU variants."
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 "Prepare both precompiled AMD artifacts first." return "Download both eGPU variants first."
} }
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
}, },
runtimeState() { runtimeState() {
@@ -48,7 +50,7 @@ export const ModelLaboratory = {
}, },
}, },
created() { created() {
this.poll = usePolling(() => this.refresh(), { interval: 5000 }) this.poll = usePolling(() => this.refresh(), { interval: 2000 })
this.poll.start() this.poll.start()
}, },
beforeUnmount() { beforeUnmount() {
@@ -62,9 +64,8 @@ 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: "AMD ready", good: true } if (m.modelLabArtifactInstalled) return { text: "eGPU variant downloaded", good: true }
if (m.modelLabArtifactAvailable) return { text: "AMD download needed", good: false } return { text: "eGPU variant not downloaded", good: false }
return { text: "AMD not published", good: false }
}, },
async refresh() { async refresh() {
try { try {
@@ -82,6 +83,7 @@ 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 : {}
@@ -95,10 +97,10 @@ export const ModelLaboratory = {
}, },
normalizeSelection() { normalizeSelection() {
const ready = this.readyModels const ready = this.readyModels
if (!this.modelById(this.configuration.lateralModel) && ready.length) { if (!ready.some((m) => m.value === this.configuration.lateralModel)) {
this.configuration.lateralModel = ready[0].value this.configuration.lateralModel = ready[0]?.value || ""
} }
if (!this.modelById(this.configuration.longitudinalModel) && ready.length > 1) { if (!ready.some((m) => m.value === this.configuration.longitudinalModel)) {
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 || ""
} }
@@ -143,8 +145,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 || "Chestnut artifact download queued.") this.message = String(payload?.message || "eGPU variant download queued.")
showSnackbar("Chestnut artifact download queued", "info") showSnackbar("eGPU variant 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)
@@ -152,6 +154,25 @@ 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">
@@ -177,12 +198,41 @@ 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>
@@ -234,30 +284,6 @@ 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>
`, `,
@@ -52,6 +52,13 @@ function normalizeRoute(r) {
} }
} }
function localDeviceUrl(ip, route = "/") {
const raw = String(ip || "").trim()
if (!raw || raw === "unknown") return ""
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
return `http://${host}:8082/#${route}`
}
export const Recordings = { export const Recordings = {
name: "Recordings", name: "Recordings",
components: { GalaxyTabs, GxNotice }, components: { GalaxyTabs, GxNotice },
@@ -75,6 +82,7 @@ export const Recordings = {
logsRoute: null, logsRoute: null,
logsData: null, logsData: null,
onFirestar: isFirestarOrigin(), onFirestar: isFirestarOrigin(),
localUrl: "",
// Screen recordings subtab // Screen recordings subtab
screenLoading: false, screenLoading: false,
screenError: "", screenError: "",
@@ -335,7 +343,14 @@ export const Recordings = {
}, },
}, },
async mounted() { async mounted() {
if (!this.onFirestar) await this.loadRoutes() if (this.onFirestar) {
try {
const status = await api.getDeviceStatus()
this.localUrl = localDeviceUrl(status?.lanIp, "/recordings")
} catch (e) {}
return
}
await this.loadRoutes()
}, },
beforeUnmount() { beforeUnmount() {
this.controller?.abort() this.controller?.abort()
@@ -503,8 +518,14 @@ export const Recordings = {
</Teleport> </Teleport>
</template> </template>
<GxNotice v-else tone="info" icon="bi-satellite" title="Recordings Unavailable via Galaxy" <GxNotice v-else tone="info" icon="bi-satellite" title="Recordings unavailable via Galaxy">
text="Loading recordings requires a direct connection. Connect to your device's local network to use this feature." /> Recordings are unavailable via Galaxy for bandwidth reasons. If you are on the same local network, connect here:
<br />
<a v-if="localUrl" class="gx-btn gx-btn--tonal" :href="localUrl" style="margin-top:var(--sp-3);">
<i class="bi bi-box-arrow-up-right"></i> Open Recordings Locally
</a>
<span v-else>your device's local IP on port 8082.</span>
</GxNotice>
</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,
slugifySectionName, resolveVehicleUnitParam, 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"
@@ -82,7 +82,9 @@ 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()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q)) const displayParam = resolveVehicleUnitParam(p, this.values)
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)
@@ -132,7 +134,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]" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
@change="onParamChange" /> @change="onParamChange" />
</template> </template>
</GalaxySection> </GalaxySection>
@@ -30,11 +30,17 @@ export const SystemTools = {
profileBusy: "", profileBusy: "",
} }
}, },
created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() }, created() {
this.poll = usePolling(() => this.loadFastStatus(), {
interval: 1000,
enabled: () => !this.fastStatus || !!this.fastStatus.running,
})
this.poll.start()
},
mounted() { this.loadBranches(); this.loadProfiles() }, mounted() { this.loadBranches(); this.loadProfiles() },
beforeUnmount() { this.poll?.destroy() }, beforeUnmount() { this.poll?.destroy() },
computed: { computed: {
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running }, updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
factoryResetStatus() { factoryResetStatus() {
const s = this.fastStatus const s = this.fastStatus
if (!s || String(s?.lastMode || "").trim() !== "factory-reset") return null if (!s || String(s?.lastMode || "").trim() !== "factory-reset") return null
@@ -53,6 +59,7 @@ export const SystemTools = {
}, },
methods: { methods: {
shortCommit, shortCommit,
toPercent,
async loadBranches() { async loadBranches() {
try { try {
const data = await api.getUpdateBranches() const data = await api.getUpdateBranches()
@@ -65,8 +72,15 @@ export const SystemTools = {
this.branchLoading = false this.branchLoading = false
} }
}, },
async loadFastStatus() { async loadFastStatus({ throwOnError = false } = {}) {
try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null } try {
const status = await api.getUpdateFastStatus()
if (!status) throw new Error("Update status unavailable")
this.fastStatus = status
} catch (e) {
this.fastStatus = null
if (throwOnError) throw e
}
}, },
async backupToggles() { async backupToggles() {
try { try {
@@ -160,6 +174,7 @@ export const SystemTools = {
try { try {
await api.setUpdateBranch(branch) await api.setUpdateBranch(branch)
showSnackbar(`Switching to ${branch}...`) showSnackbar(`Switching to ${branch}...`)
await this.loadFastStatus()
} catch (e) { } catch (e) {
showSnackbar(e?.message || "Switch failed.", "error") showSnackbar(e?.message || "Switch failed.", "error")
} }
@@ -168,7 +183,7 @@ export const SystemTools = {
if (this.busy) return if (this.busy) return
this.busy = "check" this.busy = "check"
try { try {
await this.loadFastStatus() await this.loadFastStatus({ throwOnError: true })
this.checkedForUpdates = true this.checkedForUpdates = true
const st = this.fastStatus const st = this.fastStatus
if (st?.running) showSnackbar("An update is already running.") if (st?.running) showSnackbar("An update is already running.")
@@ -227,6 +242,7 @@ export const SystemTools = {
try { try {
await api.factoryReset() await api.factoryReset()
showSnackbar("SAVE ME initiated — factory resetting...") showSnackbar("SAVE ME initiated — factory resetting...")
await this.loadFastStatus()
} catch (e) { } catch (e) {
showSnackbar(e?.message || "Factory reset failed.", "error") showSnackbar(e?.message || "Factory reset failed.", "error")
} }
@@ -256,14 +272,27 @@ export const SystemTools = {
<i class="bi bi-arrow-repeat"></i> <i class="bi bi-arrow-repeat"></i>
<span class="gx-section__title">Update Status</span> <span class="gx-section__title">Update Status</span>
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span> <span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
<span v-else-if="fastStatus.updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span> <span v-else-if="updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else class="gx-chip">Up to date</span> <span v-else-if="checkedForUpdates" class="gx-chip">Up to date</span>
<span v-else class="gx-chip">Not checked</span>
</div> </div>
<div style="padding: var(--sp-3); display:grid; gap:6px;"> <div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '' }}</span></div> <div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '' }}</span></div>
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div> <div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div> <div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div> <div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
<div v-if="fastStatus.running" class="gx-update-progress" role="progressbar" aria-label="Update progress"
:aria-valuenow="Math.round(fastStatus.progressPercent || 0)" aria-valuemin="0" aria-valuemax="100">
<div class="gx-update-progress__track">
<div class="gx-update-progress__fill" :class="{ 'gx-update-progress__fill--error': fastStatus.stage === 'error' }"
:style="{ width: toPercent(fastStatus.progressPercent) + '%' }"></div>
</div>
<div class="gx-update-progress__meta">
<span>Step {{ fastStatus.progressStep || 0 }}/{{ fastStatus.progressTotalSteps || 5 }}: {{ fastStatus.progressLabel || fastStatus.stage || 'Updating' }}</span>
<strong>{{ Math.round(toPercent(fastStatus.progressPercent)) }}%</strong>
</div>
<small v-if="fastStatus.progressDetail">{{ fastStatus.progressDetail }}</small>
</div>
<div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div> <div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div> <div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;"> <div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
@@ -287,7 +316,7 @@ export const SystemTools = {
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i> <i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }} <i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
</button> </button>
<button type="button" class="gx-btn" :disabled="!updateAvailable || !!busy || isOnroad" @click="applyFastUpdate"> <button v-if="updateAvailable" type="button" class="gx-btn" :disabled="!!busy || isOnroad" @click="applyFastUpdate">
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }} <i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
</button> </button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button> <button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
@@ -1,17 +1,17 @@
import { navigate, toolHref } from "../store.js" import { navigate, toolHref } from "../store.js"
const TOOLS = [ const TOOLS = [
{ name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video", desc: "PiP side camera & V-ASM spot monitor" }, { name: "Bluetooth", link: "/bluetooth", icon: "bi-bluetooth", desc: "Pair devices, controllers, & audio" },
{ name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video", desc: "Sentry, PiP side camera, & V-ASM spot monitor" },
{ name: "Galaxy & App Install", link: "/galaxy", icon: "bi-globe2", desc: "Remote access, pairing, & app install" }, { name: "Galaxy & App Install", link: "/galaxy", icon: "bi-globe2", desc: "Remote access, pairing, & app install" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" }, { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
{ name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2", desc: "Pair lateral and longitudinal models" }, { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2", desc: "Pair lateral and longitudinal models" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" }, { name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" }, { name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" }, { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" },
{ name: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed tuning, live plots, testing grounds" }, { name: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed tuning, live plots, testing grounds" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" }, { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Vehicle features" },
].sort((a, b) => a.name.localeCompare(b.name)) ].sort((a, b) => a.name.localeCompare(b.name))
export const Tools = { export const Tools = {
@@ -1,5 +1,4 @@
import { LateralTuningPanel } from "../components/LateralTuningPanel.js" import { LateralTuningPanel } from "../components/LateralTuningPanel.js"
import { LongitudinalManeuvers } from "../components/LongitudinalManeuvers.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js" import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { Plots } from "./Plots.js" import { Plots } from "./Plots.js"
import { TestingGround } from "./TestingGround.js" import { TestingGround } from "./TestingGround.js"
@@ -7,16 +6,15 @@ import { useTabRouting } from "../composables.js"
const TABS = { const TABS = {
lateral: "Lateral Tuning", lateral: "Lateral Tuning",
long: "Long Maneuvers",
plots: "Plots", plots: "Plots",
testing: "Testing Ground", testing: "Testing Ground",
} }
export const Tuning = { export const Tuning = {
name: "Tuning", name: "Tuning",
components: { LateralTuningPanel, LongitudinalManeuvers, Plots, TestingGround, GalaxyTabs }, components: { LateralTuningPanel, Plots, TestingGround, GalaxyTabs },
setup() { setup() {
return useTabRouting("/tuning", { lateral: "lateral", long: "long", plots: "plots", testing: "testing" }) return useTabRouting("/tuning", { lateral: "lateral", plots: "plots", testing: "testing" })
}, },
data() { return { TABS } }, data() { return { TABS } },
template: ` template: `
@@ -29,10 +27,6 @@ export const Tuning = {
<LateralTuningPanel /> <LateralTuningPanel />
</template> </template>
<template v-else-if="tab === 'long'">
<LongitudinalManeuvers />
</template>
<template v-else-if="tab === 'plots'"> <template v-else-if="tab === 'plots'">
<Plots :embedded="true" /> <Plots :embedded="true" />
</template> </template>
@@ -1,36 +1,22 @@
import { api, showSnackbar } from "../api.js" import { api, showSnackbar } from "../api.js"
import { navigate, toolHref } from "../store.js" import { navigate, toolHref } from "../store.js"
import { WheelControls } from "../components/WheelControls.js"
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { GalaxySection } from "../components/GalaxySection.js" import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js"
const FEATURES = [ const FEATURES = [
{ key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" }, { key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" },
{ key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" }, { key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" },
] ]
const TABS = {
controllers: "Controllers",
bluetooth: "Bluetooth",
features: "Vehicle Features",
}
export const Vehicle = { export const Vehicle = {
name: "Vehicle", name: "Vehicle",
components: { WheelControls, BluetoothPanel, GalaxySection, GalaxyTabs }, components: { GalaxySection },
data() { data() {
return { return {
TABS,
features: FEATURES, features: FEATURES,
featureStatus: {}, featureStatus: {},
busy: "", busy: "",
} }
}, },
setup() {
return useTabRouting("/vehicle", { controllers: "controllers", bluetooth: "bluetooth", features: "features" })
},
computed: { computed: {
featureList() { return this.features }, featureList() { return this.features },
}, },
@@ -65,34 +51,22 @@ export const Vehicle = {
<div class="gx-view"> <div class="gx-view">
<h2 style="margin-top:0;">Vehicle Controls</h2> <h2 style="margin-top:0;">Vehicle Controls</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" /> <GalaxySection title="Vehicle Features" icon="bi-check2-square">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<template v-if="tab === 'controllers'"> <button v-for="f in featureList" :key="f.key" type="button"
<WheelControls /> class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
</template> @click="openFeature(f)">
<div class="gx-row__info">
<template v-else-if="tab === 'bluetooth'"> <span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
<BluetoothPanel /> <span class="gx-row__desc">{{ f.desc }}</span>
</template> </div>
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
<template v-else> <span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
<GalaxySection title="Vehicle Features" icon="bi-check2-square"> <i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
<div style="padding: var(--sp-3); display:grid; gap:8px;"> </button>
<button v-for="f in featureList" :key="f.key" type="button" <p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;" </div>
@click="openFeature(f)"> </GalaxySection>
<div class="gx-row__info">
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
<span class="gx-row__desc">{{ f.desc }}</span>
</div>
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
</button>
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
</div>
</GalaxySection>
</template>
</div> </div>
`, `,
} }
@@ -1,6 +1,6 @@
{ {
"name": "Big Dipper", "name": "Galaxy",
"short_name": "Big Dipper", "short_name": "Galaxy",
"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,7 +8,6 @@
{ "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-4"> <link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-5">
<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-2"> <link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-3">
<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-7").catch((err) => { import("/assets/components/router.js?v=router-cycle-fix-8").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,9 +1859,16 @@ 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"] == ""
@@ -1899,6 +1906,12 @@ 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,
@@ -1974,10 +1987,16 @@ 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 "precompiled AMD" in params_memory.values["ModelDownloadProgress"] assert "eGPU variant" 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",
@@ -1986,6 +2005,21 @@ 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,6 +46,23 @@ 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 "unit_search_terms" in source
assert "per click" in source
assert "ds-unit-note" not in source
def test_device_settings_supports_vehicle_make_exclusions():
source = _device_settings()
assert "excluded_vehicle_makes" 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()
@@ -66,6 +66,15 @@ def test_galaxy_layout_contains_basic_mode_controls():
assert {"AlphaLongitudinalEnabled", "ForceOffroad", "GalaxyDeveloperMode"} <= sections["Developer"].keys() assert {"AlphaLongitudinalEnabled", "ForceOffroad", "GalaxyDeveloperMode"} <= sections["Developer"].keys()
def test_galaxy_new_ui_is_the_visible_default_choice():
galaxy_default = _params_by_section(_layout())["Developer"]["GalaxyMobileDefault"]
assert _declared_default("GalaxyMobileDefault") == "1"
assert galaxy_default["settings_tier"] == "simple"
assert galaxy_default["label"] == "Use Galaxy (new) by Default"
assert "Galaxy (old)" in galaxy_default["description"]
def test_ford_lateral_controls_are_ford_only_and_galaxy_only(): def test_ford_lateral_controls_are_ford_only_and_galaxy_only():
lateral = _params_by_section(_layout())["Lateral (Steering)"] lateral = _params_by_section(_layout())["Lateral (Steering)"]
ford_keys = { ford_keys = {
@@ -107,6 +116,50 @@ 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_cruise_controls_are_split_between_toyota_and_software_cruise():
longitudinal = _params_by_section(_layout())["Longitudinal (Speed & Following)"]
assert longitudinal["CustomCruise"]["excluded_vehicle_makes"] == ["Lexus", "Toyota"]
assert longitudinal["CustomCruiseLong"]["excluded_vehicle_makes"] == ["Lexus", "Toyota"]
assert longitudinal["ReverseCruise"]["vehicle_makes"] == ["Lexus", "Toyota"]
assert _declared_default("ReverseCruise") == "0"
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,6 +7,7 @@ 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"
@@ -23,7 +24,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-7" in index assert "/assets/components/router.js?v=router-cycle-fix-8" in index
def test_bluetooth_actions_use_reactive_disabled_bindings(): def test_bluetooth_actions_use_reactive_disabled_bindings():
@@ -76,6 +77,16 @@ 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")
@@ -117,8 +128,13 @@ 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 "Nothing is compiled on the comma" in source assert "Download eGPU-compatible small models" in source
assert "run every camera frame on Chestnut's AMD GPU" in source assert "Choose from downloaded eGPU variant combinations below" 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
@@ -135,5 +151,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-5' in ROUTER_PATH.read_text(encoding="utf-8") assert 'model_laboratory.js?v=model-lab-6' in ROUTER_PATH.read_text(encoding="utf-8")
assert 'model_laboratory.css?v=model-lab-4' in INDEX_PATH.read_text(encoding="utf-8") assert 'model_laboratory.css?v=model-lab-5' in INDEX_PATH.read_text(encoding="utf-8")
@@ -231,6 +231,25 @@ 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,6 +62,26 @@ 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")
@@ -44,6 +44,7 @@ def test_ui_app_shell_files_exist():
"js/views/Tuning.js", "js/views/Tuning.js",
"js/views/Navigation.js", "js/views/Navigation.js",
"js/views/Vehicle.js", "js/views/Vehicle.js",
"js/views/Bluetooth.js",
"js/views/SystemTools.js", "js/views/SystemTools.js",
] ]
for rel in required: for rel in required:
@@ -55,6 +56,8 @@ def test_ui_index_wires_vue_and_mount_point():
assert 'id="galaxy-app"' in index assert 'id="galaxy-app"' in index
assert 'src="/assets/mobile/js/app.js"' in index assert 'src="/assets/mobile/js/app.js"' in index
assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index
assert '<title>Galaxy</title>' in index
assert 'apple-mobile-web-app-title" content="Galaxy"' in index
def test_ui_uses_same_backend_endpoints(): def test_ui_uses_same_backend_endpoints():
@@ -107,7 +110,7 @@ def test_ui_ports_all_tool_views():
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"], "js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot"], "js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot"],
"js/components/TroubleshootPanel.js": ["getTroubleshoot", "resetTroubleshootSection", "GalaxyConfirm"], "js/components/TroubleshootPanel.js": ["getTroubleshoot", "resetTroubleshootSection", "GalaxyConfirm"],
"js/views/Tuning.js": ["LateralTuningPanel", "LongitudinalManeuvers"], "js/views/Tuning.js": ["LateralTuningPanel"],
"js/views/Navigation.js": ["getNavigation", "setNavigation", "MapsPanel", "NavigationKeysPanel"], "js/views/Navigation.js": ["getNavigation", "setNavigation", "MapsPanel", "NavigationKeysPanel"],
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"], "js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
"js/views/SystemTools.js": [ "js/views/SystemTools.js": [
@@ -122,7 +125,9 @@ def test_ui_ports_all_tool_views():
for ep in endpoints: for ep in endpoints:
assert ep in src, f"{rel} should use api.{ep}" assert ep in src, f"{rel} should use api.{ep}"
vehicle = _read("js/views/Vehicle.js") vehicle = _read("js/views/Vehicle.js")
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle and "carFeaturesCheck" in vehicle bluetooth = _read("js/views/Bluetooth.js")
assert "WheelControls" not in vehicle and "BluetoothPanel" not in vehicle and "carFeaturesCheck" in vehicle
assert "BluetoothPanel" in bluetooth and "WheelControls" in bluetooth
def test_ui_routes_ported_views_natively_no_classic_fallback(): def test_ui_routes_ported_views_natively_no_classic_fallback():
@@ -130,16 +135,16 @@ def test_ui_routes_ported_views_natively_no_classic_fallback():
shell = _read("js/components/AppShell.js") shell = _read("js/components/AppShell.js")
tools = _read("js/views/Tools.js") tools = _read("js/views/Tools.js")
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]: for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "Bluetooth", "SystemTools"]:
assert view in app, f"app.js should register {view}" assert view in app, f"app.js should register {view}"
# Ported routes must resolve natively in the Vue app (zero /classic redirect). # Ported routes must resolve natively in the Vue app (zero /classic redirect).
for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system"]: for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/bluetooth", "/system"]:
assert route in shell, f"AppShell should route {route} natively" assert route in shell, f"AppShell should route {route} natively"
assert route in app, f"app.js should resolve {route} natively" assert route in app, f"app.js should resolve {route} natively"
# Tools grid routes the native categories (Recordings lives in the bottom nav # Tools grid routes the native categories (Recordings lives in the bottom nav
# and is intentionally absent from the Tools page). # and is intentionally absent from the Tools page).
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]: for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/bluetooth", "/system"]:
assert tool in tools, f"Tools grid should route {tool} natively" assert tool in tools, f"Tools grid should route {tool} natively"
assert "/cameras" in tools, "Tools grid should route the camera hub natively" assert "/cameras" in tools, "Tools grid should route the camera hub natively"
assert "/manage_v_asm" not in tools and "/manage_pip_sidecam" not in tools assert "/manage_v_asm" not in tools and "/manage_pip_sidecam" not in tools
@@ -190,6 +195,23 @@ 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 "gx-unit-note" not 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")
@@ -209,11 +231,15 @@ def test_ui_centralizes_api_and_uses_composables():
def test_ui_schema_driven_param_engine_reused(): def test_ui_schema_driven_param_engine_reused():
tuning = _read("js/views/Tuning.js") tuning = _read("js/views/Tuning.js")
assert "GalaxyEmbed" not in tuning and 'src="/tuning"' not in tuning, "Tuning must be native, not a classic embed" assert "GalaxyEmbed" not in tuning and 'src="/tuning"' not in tuning, "Tuning must be native, not a classic embed"
assert "LateralTuningPanel" in tuning and "LongitudinalManeuvers" in tuning assert "LateralTuningPanel" in tuning and "LongitudinalManeuvers" not in tuning
vehicle = _read("js/views/Vehicle.js") vehicle = _read("js/views/Vehicle.js")
bluetooth = _read("js/views/Bluetooth.js")
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles" assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle assert "WheelControls" not in vehicle and "BluetoothPanel" not in vehicle
assert "GalaxySection" in vehicle assert "GalaxySection" in vehicle
assert "WheelControls" in bluetooth and "BluetoothPanel" in bluetooth
assert bluetooth.index('bluetooth: "Bluetooth"') < bluetooth.index('controllers: "Controllers"')
assert 'useTabRouting("/bluetooth"' in bluetooth
engine = _read("js/components/ParamSections.js") engine = _read("js/components/ParamSections.js")
assert "SettingTree" in engine assert "SettingTree" in engine
assert "isSettingVisible" in engine assert "isSettingVisible" in engine
@@ -268,6 +294,7 @@ def test_ui_has_bottom_navigation_and_drawer():
assert "gx-drawer" in shell assert "gx-drawer" in shell
assert "gx-appbar" in shell assert "gx-appbar" in shell
assert "Search toggles" in shell assert "Search toggles" in shell
assert ">Galaxy</span>" in shell
def test_ui_search_visible_on_mobile_and_content_full_width(): def test_ui_search_visible_on_mobile_and_content_full_width():
@@ -325,8 +352,6 @@ def test_ui_galaxy_background_is_css_only_and_lightweight():
def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile(): def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
source = GALAXY_PY.read_text(encoding="utf-8") source = GALAXY_PY.read_text(encoding="utf-8")
# The classic Galaxy SPA is the default landing at / (original behaviour) unless
# the "New Galaxy by Default" (GalaxyMobileDefault) toggle is enabled.
assert '@app.route("/", methods=["GET"])' in source assert '@app.route("/", methods=["GET"])' in source
assert 'render_template("index.html")' in source assert 'render_template("index.html")' in source
assert 'params.get_bool("GalaxyMobileDefault")' in source assert 'params.get_bool("GalaxyMobileDefault")' in source
@@ -341,9 +366,10 @@ def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
def test_ui_manifest_is_valid_pwa_manifest(): def test_ui_manifest_is_valid_pwa_manifest():
manifest = json.loads((UI_ROOT / "manifest.json").read_text(encoding="utf-8")) manifest = json.loads((UI_ROOT / "manifest.json").read_text(encoding="utf-8"))
assert manifest["display"] == "standalone" assert manifest["display"] == "standalone"
assert manifest["name"] assert manifest["name"] == "Galaxy"
assert manifest["short_name"] == "Galaxy"
assert manifest["icons"] assert manifest["icons"]
assert manifest["start_url"] == "/mobile/" assert "start_url" not in manifest
def test_ui_ported_classic_tools_native_no_embed(): def test_ui_ported_classic_tools_native_no_embed():
@@ -400,7 +426,6 @@ def test_ui_all_remaining_classic_tools_native_no_embed():
# Standalone native views + their routes. # Standalone native views + their routes.
native = { native = {
"/sentry": "Sentry",
"/manage_models": "ModelManager", "/manage_models": "ModelManager",
"/plots": "Plots", "/plots": "Plots",
"/testing_ground": "TestingGround", "/testing_ground": "TestingGround",
@@ -414,6 +439,10 @@ def test_ui_all_remaining_classic_tools_native_no_embed():
assert src, f"missing view: {view}" assert src, f"missing view: {view}"
assert "GalaxyEmbed" not in src and "fetch(" not in src, f"{view} should be native with no raw fetch" assert "GalaxyEmbed" not in src and "fetch(" not in src, f"{view} should be native with no raw fetch"
assert '"/sentry": Cameras' in app
sentry = _read("js/views/Sentry.js")
assert "GalaxyEmbed" not in sentry and "fetch(" not in sentry
# Navigation maps + App Keys and Tuning lateral are native tabs now. # Navigation maps + App Keys and Tuning lateral are native tabs now.
nav = _read("js/views/Navigation.js") nav = _read("js/views/Navigation.js")
assert "GalaxyEmbed" not in nav and "MapsPanel" in nav and "NavigationKeysPanel" in nav assert "GalaxyEmbed" not in nav and "MapsPanel" in nav and "NavigationKeysPanel" in nav
@@ -449,8 +478,9 @@ def test_ui_cameras_hub_vasm_and_pip_native_no_embed():
assert "/cameras" in app and "Cameras" in app, "app.js should register the camera hub" assert "/cameras" in app and "Cameras" in app, "app.js should register the camera hub"
assert "/cameras" in store, "store NATIVE_ROOTS should include /cameras" assert "/cameras" in store, "store NATIVE_ROOTS should include /cameras"
assert "GalaxyEmbed" not in cameras and "fetch(" not in cameras assert "GalaxyEmbed" not in cameras and "fetch(" not in cameras
assert "Vasm" in cameras and "Pip" in cameras, "camera hub should embed V-ASM and PiP" assert "Sentry" in cameras and "Vasm" in cameras and "Pip" in cameras, "camera hub should embed Sentry, V-ASM, and PiP"
assert "GalaxyTabs" in cameras assert "GalaxyTabs" in cameras
assert cameras.index('sentry: "Sentry Mode"') < cameras.index('vasm: "V-ASM Spot Monitor"')
# Removed standalone pages are no longer routed or listed as native roots. # Removed standalone pages are no longer routed or listed as native roots.
for route in ["/manage_v_asm", "/manage_pip_sidecam"]: for route in ["/manage_v_asm", "/manage_pip_sidecam"]:
@@ -470,6 +500,44 @@ def test_ui_cameras_hub_vasm_and_pip_native_no_embed():
assert method in api, f"api.js should expose {method}" assert method in api, f"api.js should expose {method}"
def test_ui_mobile_polish_regressions():
system = _read("js/views/SystemTools.js")
css = _read("css/material.css")
assert 'button v-if="updateAvailable"' in system
assert "checkedForUpdates && !!this.fastStatus?.updateAvailable" in system
assert "gx-update-progress__fill" in system
assert "linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%)" in css
bluetooth = _read("js/components/BluetoothPanel.js")
assert "methods: {\n address," in bluetooth
logs = _read("js/views/Logs.js")
assert logs.index('troubleshoot: "Troubleshoot"') < logs.index('errors: "Error Logs"') < logs.index('tmux: "Tmux Live Log"')
troubleshoot = _read("js/components/TroubleshootPanel.js")
assert "gx-diagnostic-row--changed" in troubleshoot and ".gx-row.gx-diagnostic-row--changed" in css
recordings = _read("js/views/Recordings.js")
galaxy = _read("js/views/Galaxy.js")
assert "bandwidth reasons" in recordings and "status?.lanIp" in recordings
assert "status?.lanIp" in galaxy
assert ':href="localUrl"' in recordings and ':href="localUrl"' in galaxy
assert 'localDeviceUrl(status?.lanIp, "/recordings")' in recordings
assert 'localDeviceUrl(status?.lanIp, "/galaxy")' in galaxy
assert "gx-btn gx-btn--tonal" in recordings and "Open Recordings Locally" in recordings
assert "gx-btn gx-btn--tonal" in galaxy and "Open Galaxy Locally" in galaxy
home = _read("js/views/Home.js")
home_css = _read("css/home.css")
assert "backgroundImage: modelView.style" in home
assert "display: flex" in home_css and "flex-direction: column" in home_css
tuning = _read("js/views/Tuning.js")
classic_sidebar = (REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js").read_text(encoding="utf-8")
c4_developer = (REPO_ROOT / "selfdrive/ui/layouts/settings/developer.py").read_text(encoding="utf-8")
assert "LongitudinalManeuvers" not in tuning and "Long Maneuvers" not in classic_sidebar
assert 'tr("Longitudinal Maneuver Mode")' not in c4_developer
def _node_exe(): def _node_exe():
candidates = [ candidates = [
shutil.which("node"), shutil.which("node"),
@@ -505,6 +573,23 @@ 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")
+86 -22
View File
@@ -5015,6 +5015,7 @@ 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",
@@ -5181,6 +5182,7 @@ 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"
@@ -5190,13 +5192,16 @@ 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"}: if operation not in {"action", "learn", "cancel", "delete", "clear", "test", "test-stop", "joystick", "offroad-disconnect"}:
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()
@@ -5296,6 +5301,27 @@ 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": "Galaxy manifest not found"}), 404
try:
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return jsonify({"error": "Galaxy 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():
@@ -6322,21 +6348,24 @@ 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":
@@ -6375,7 +6404,8 @@ 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:
message = "Model Laboratory disabled." restored_model = _activate_preferred_model_profile()
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
@@ -6383,8 +6413,6 @@ 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 "")
@@ -6398,16 +6426,50 @@ 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 can be prepared for Model Laboratory."}), 409 return jsonify({"error": "Only compatible small models have Model Laboratory eGPU variants."}), 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"\"{model['label']}\" is already prepared for Chestnut."}), 200 return jsonify({"message": f"The eGPU variant for \"{model['label']}\" is already downloaded."}), 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, "Downloading precompiled AMD artifact...") params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Starting eGPU variant download...")
return jsonify({"message": f"Started preparing \"{model['label']}\" for Chestnut."}), 200 return jsonify({"message": f"Started downloading the eGPU variant for \"{model['label']}\"."}), 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():
@@ -6461,8 +6523,9 @@ 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": "Active Big disabled. Active Small will be used even when Chestnut is connected.", "message": f"Active Big disabled. {restored_model} will be used even when Chestnut is connected.",
"profile": profile, "profile": profile,
"model": "", "model": "",
}), 200 }), 200
@@ -6484,8 +6547,9 @@ 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']}'.", "message": f"Active {profile.title()} set to '{model['label']}'. {active_model} will be used next.",
"profile": profile, "profile": profile,
"model": model_key, "model": model_key,
}), 200 }), 200
+30 -9
View File
@@ -22,7 +22,7 @@ DEVELOPER_METRIC_DISPLAY_KEYS = (
) )
DEVICE_SHUTDOWN_KEY = "DeviceShutdown" DEVICE_SHUTDOWN_KEY = "DeviceShutdown"
CAMERA_VIEW_KEY = "CameraView" CAMERA_VIEW_KEY = "CameraView"
REVERSE_CRUISE_KEY = "ReverseCruise" GALAXY_NEW_DEFAULT_KEY = "GalaxyMobileDefault"
DEFAULT_STEER_KP = 0.6 DEFAULT_STEER_KP = 0.6
LEGACY_STEER_KP = 0.7 LEGACY_STEER_KP = 0.7
@@ -39,7 +39,8 @@ LANE_CHANGE_SMOOTHING_MIGRATION_MARKER = ".starpilot_lane_change_smoothing_defau
SPEED_LIMIT_VISIBILITY_MIGRATION_MARKER = ".starpilot_speed_limit_visibility_v1" SPEED_LIMIT_VISIBILITY_MIGRATION_MARKER = ".starpilot_speed_limit_visibility_v1"
DEVICE_SHUTDOWN_HOURS_MIGRATION_MARKER = ".starpilot_device_shutdown_hours_v1" DEVICE_SHUTDOWN_HOURS_MIGRATION_MARKER = ".starpilot_device_shutdown_hours_v1"
CAMERA_VIEW_DEFAULT_MIGRATION_MARKER = ".starpilot_camera_view_default_v1" CAMERA_VIEW_DEFAULT_MIGRATION_MARKER = ".starpilot_camera_view_default_v1"
REVERSE_CRUISE_REMOVAL_MIGRATION_MARKER = ".starpilot_remove_reverse_cruise_v1" REVERSE_CRUISE_RESTORE_MIGRATION_MARKER = ".starpilot_restore_reverse_cruise_v1"
GALAXY_NEW_DEFAULT_MIGRATION_MARKER = ".starpilot_galaxy_new_default_v1"
MARKER_DIRNAME = ".starpilot_param_migrations" MARKER_DIRNAME = ".starpilot_param_migrations"
LATERAL_METHOD_PARAM_SUFFIXES = ( LATERAL_METHOD_PARAM_SUFFIXES = (
@@ -147,8 +148,12 @@ def _camera_view_default_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / CAMERA_VIEW_DEFAULT_MIGRATION_MARKER return _marker_dir_path(params) / CAMERA_VIEW_DEFAULT_MIGRATION_MARKER
def _reverse_cruise_removal_marker_path(params: ParamsLike) -> Path: def _reverse_cruise_restore_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / REVERSE_CRUISE_REMOVAL_MIGRATION_MARKER return _marker_dir_path(params) / REVERSE_CRUISE_RESTORE_MIGRATION_MARKER
def _galaxy_new_default_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / GALAXY_NEW_DEFAULT_MIGRATION_MARKER
def _marker_dir_path(params: ParamsLike) -> Path: def _marker_dir_path(params: ParamsLike) -> Path:
@@ -332,12 +337,24 @@ def _apply_camera_view_default_migration(params: ParamsLike, marker: Path) -> No
marker.touch() marker.touch()
def _remove_reverse_cruise_param(params: ParamsLike, marker: Path) -> None: def _restore_reverse_cruise_param(params: ParamsLike, marker: Path) -> None:
if marker.exists(): if marker.exists():
return return
marker.parent.mkdir(parents=True, exist_ok=True) marker.parent.mkdir(parents=True, exist_ok=True)
Path(params.get_param_path(REVERSE_CRUISE_KEY)).unlink(missing_ok=True) if (not _param_file_exists(params, "ReverseCruise") and
_approx_equal(params.get_float("CustomCruise"), 5.0) and
_approx_equal(params.get_float("CustomCruiseLong"), 1.0)):
params.put_bool("ReverseCruise", True)
marker.touch()
def _enable_galaxy_new_default(params: ParamsLike, marker: Path) -> None:
if marker.exists():
return
marker.parent.mkdir(parents=True, exist_ok=True)
params.put_bool(GALAXY_NEW_DEFAULT_KEY, True)
marker.touch() marker.touch()
@@ -352,7 +369,8 @@ def apply_launch_param_migrations(params: ParamsLike, marker_path: Path | None =
speed_limit_visibility_marker_path: Path | None = None, speed_limit_visibility_marker_path: Path | None = None,
device_shutdown_hours_marker_path: Path | None = None, device_shutdown_hours_marker_path: Path | None = None,
camera_view_default_marker_path: Path | None = None, camera_view_default_marker_path: Path | None = None,
reverse_cruise_removal_marker_path: Path | None = None) -> None: reverse_cruise_restore_marker_path: Path | None = None,
galaxy_new_default_marker_path: Path | None = None) -> None:
_apply_legacy_launch_param_migrations(params, marker_path or _default_marker_path(params)) _apply_legacy_launch_param_migrations(params, marker_path or _default_marker_path(params))
# Keep branch-default rollout on its own marker so older installs that already # Keep branch-default rollout on its own marker so older installs that already
# have the legacy marker still receive this one-time param reset. # have the legacy marker still receive this one-time param reset.
@@ -384,8 +402,11 @@ def apply_launch_param_migrations(params: ParamsLike, marker_path: Path | None =
_apply_camera_view_default_migration( _apply_camera_view_default_migration(
params, camera_view_default_marker_path or _camera_view_default_marker_path(params) params, camera_view_default_marker_path or _camera_view_default_marker_path(params)
) )
_remove_reverse_cruise_param( _restore_reverse_cruise_param(
params, reverse_cruise_removal_marker_path or _reverse_cruise_removal_marker_path(params) params, reverse_cruise_restore_marker_path or _reverse_cruise_restore_marker_path(params)
)
_enable_galaxy_new_default(
params, galaxy_new_default_marker_path or _galaxy_new_default_marker_path(params)
) )
+1 -1
View File
@@ -71,7 +71,7 @@ STARPILOT_DEFAULT_MODEL_MIGRATION_FLAG = Path("/data") / "starpilot_default_mode
STARPILOT_CE_MODEL_STOP_TIME_MIGRATION_FLAG = Path("/data") / "starpilot_ce_model_stop_time_v2" STARPILOT_CE_MODEL_STOP_TIME_MIGRATION_FLAG = Path("/data") / "starpilot_ce_model_stop_time_v2"
STARPILOT_LEGACY_CACHE_MARKER_KEYS = ("RemapCancelToDistance",) STARPILOT_LEGACY_CACHE_MARKER_KEYS = ("RemapCancelToDistance",)
STARPILOT_REMOVED_PARAM_KEYS = ( STARPILOT_REMOVED_PARAM_KEYS = (
"CoastUpToLeads", "HumanAcceleration", "HumanFollowing", "PrioritizeSmoothFollowing", "ReverseCruise", "CoastUpToLeads", "HumanAcceleration", "HumanFollowing", "PrioritizeSmoothFollowing",
) )
LEGACY_CARMODEL_MIGRATIONS = { LEGACY_CARMODEL_MIGRATIONS = {
"CHEVROLET_BOLT_CC_2019_2021": "CHEVROLET_BOLT_CC_2018_2021", "CHEVROLET_BOLT_CC_2019_2021": "CHEVROLET_BOLT_CC_2018_2021",
@@ -7,6 +7,7 @@ from openpilot.system.manager.launch_param_migrations import (
DEFAULT_CAMERA_VIEW, DEFAULT_CAMERA_VIEW,
DEVELOPER_METRIC_DISPLAY_KEYS, DEVELOPER_METRIC_DISPLAY_KEYS,
DEVELOPER_METRIC_DISPLAY_MIGRATION_MARKER, DEVELOPER_METRIC_DISPLAY_MIGRATION_MARKER,
GALAXY_NEW_DEFAULT_MIGRATION_MARKER,
DEFAULT_LANE_CHANGE_SMOOTHING, DEFAULT_LANE_CHANGE_SMOOTHING,
DEFAULT_STEER_KP, DEFAULT_STEER_KP,
DEVICE_SHUTDOWN_HOURS_MIGRATION_MARKER, DEVICE_SHUTDOWN_HOURS_MIGRATION_MARKER,
@@ -14,7 +15,7 @@ from openpilot.system.manager.launch_param_migrations import (
LAUNCH_PARAM_MIGRATION_MARKER, LAUNCH_PARAM_MIGRATION_MARKER,
LATERAL_METHOD_REBRAND_MIGRATION_MARKER, LATERAL_METHOD_REBRAND_MIGRATION_MARKER,
MARKER_DIRNAME, MARKER_DIRNAME,
REVERSE_CRUISE_REMOVAL_MIGRATION_MARKER, REVERSE_CRUISE_RESTORE_MIGRATION_MARKER,
STANDARD_ACCELERATION_PROFILE, STANDARD_ACCELERATION_PROFILE,
SPEED_LIMIT_VISIBILITY_MIGRATION_MARKER, SPEED_LIMIT_VISIBILITY_MIGRATION_MARKER,
LEGACY_UI_SELECTION_MIGRATION_MARKER, LEGACY_UI_SELECTION_MIGRATION_MARKER,
@@ -178,14 +179,42 @@ def test_apply_launch_param_migrations_preserves_custom_camera_view(tmp_path):
assert params.get_int("CameraView") == 0 assert params.get_int("CameraView") == 0
def test_apply_launch_param_migrations_removes_reverse_cruise_param(tmp_path): def test_apply_launch_param_migrations_restores_reverse_cruise_from_swapped_intervals(tmp_path):
params = FileBackedFakeParams(tmp_path / "params") params = FileBackedFakeParams(tmp_path / "params")
params.put_bool("ReverseCruise", True) params.put_float("CustomCruise", 5.0)
params.put_float("CustomCruiseLong", 1.0)
apply_launch_param_migrations(params) apply_launch_param_migrations(params)
assert not Path(params.get_param_path("ReverseCruise")).exists() assert params.get_bool("ReverseCruise")
assert marker_path(tmp_path, REVERSE_CRUISE_REMOVAL_MIGRATION_MARKER).is_file() assert marker_path(tmp_path, REVERSE_CRUISE_RESTORE_MIGRATION_MARKER).is_file()
def test_apply_launch_param_migrations_preserves_explicit_reverse_cruise_choice(tmp_path):
params = FileBackedFakeParams(tmp_path / "params")
params.put_float("CustomCruise", 5.0)
params.put_float("CustomCruiseLong", 1.0)
params.put_bool("ReverseCruise", False)
apply_launch_param_migrations(params)
assert not params.get_bool("ReverseCruise")
def test_apply_launch_param_migrations_enables_galaxy_new_default_once(tmp_path):
params = FileBackedFakeParams(tmp_path / "params")
params.put_bool("GalaxyMobileDefault", False)
apply_launch_param_migrations(params)
assert params.get_bool("GalaxyMobileDefault")
marker = marker_path(tmp_path, GALAXY_NEW_DEFAULT_MIGRATION_MARKER)
assert marker.is_file()
params.put_bool("GalaxyMobileDefault", False)
apply_launch_param_migrations(params)
assert not params.get_bool("GalaxyMobileDefault")
def test_apply_launch_param_migrations_applies_branch_defaults_for_existing_installs(tmp_path): def test_apply_launch_param_migrations_applies_branch_defaults_for_existing_installs(tmp_path):
+1 -3
View File
@@ -397,7 +397,6 @@ class TestManager:
params_cache = FileBackedFakeParams(tmp_path / "cache", { params_cache = FileBackedFakeParams(tmp_path / "cache", {
"HumanFollowing": False, "HumanFollowing": False,
"PrioritizeSmoothFollowing": True, "PrioritizeSmoothFollowing": True,
"ReverseCruise": True,
}) })
manager.cleanup_removed_starpilot_params(params, params_cache) manager.cleanup_removed_starpilot_params(params, params_cache)
@@ -405,10 +404,9 @@ class TestManager:
assert not Path(params.get_param_path("CoastUpToLeads")).exists() assert not Path(params.get_param_path("CoastUpToLeads")).exists()
assert not Path(params.get_param_path("HumanAcceleration")).exists() assert not Path(params.get_param_path("HumanAcceleration")).exists()
assert not Path(params.get_param_path("HumanFollowing")).exists() assert not Path(params.get_param_path("HumanFollowing")).exists()
assert not Path(params.get_param_path("ReverseCruise")).exists() assert params.get_bool("ReverseCruise")
assert not Path(params_cache.get_param_path("HumanFollowing")).exists() assert not Path(params_cache.get_param_path("HumanFollowing")).exists()
assert not Path(params_cache.get_param_path("PrioritizeSmoothFollowing")).exists() assert not Path(params_cache.get_param_path("PrioritizeSmoothFollowing")).exists()
assert not Path(params_cache.get_param_path("ReverseCruise")).exists()
def test_migrate_legacy_starpilot_params_cache_copies_marker_sources(self, tmp_path, monkeypatch): def test_migrate_legacy_starpilot_params_cache_copies_marker_sources(self, tmp_path, monkeypatch):
monkeypatch.setattr(manager, "STARPILOT_PARAMS_CACHE_MIGRATION_FLAG", tmp_path / "starpilot_params_cache_v1") monkeypatch.setattr(manager, "STARPILOT_PARAMS_CACHE_MIGRATION_FLAG", tmp_path / "starpilot_params_cache_v1")