Compare commits

..

1 Commits

Author SHA1 Message Date
firestarsdog ee05336586 Implement TripleDipper GPS fallback 2026-09-08 01:05:57 -04:00
101 changed files with 1221 additions and 1701 deletions
+19 -2
View File
@@ -1,8 +1,25 @@
from __future__ import annotations
from cereal import car
from openpilot.common.params import Params from openpilot.common.params import Params
def get_gps_location_service(params: Params) -> str: def gm_car_params_present(params: Params, CP: car.CarParams | None = None) -> bool:
if params.get_bool("UbloxAvailable") or params.get_bool("CarGpsAvailable"): if CP is not None and getattr(CP, "brand", None):
return CP.brand == "gm"
try:
raw_car_params = params.get("CarParams")
if raw_car_params is None:
return False
with car.CarParams.from_bytes(raw_car_params) as parsed_cp:
return parsed_cp.brand == "gm"
except Exception:
return False
def get_gps_location_service(params: Params, CP: car.CarParams | None = None) -> str:
# GM arbitrates device/PPS/OnStar through gpsLocationExternal.
if gm_car_params_present(params, CP) or params.get_bool("UbloxAvailable") or params.get_bool("CarGpsAvailable"):
return "gpsLocationExternal" return "gpsLocationExternal"
else: else:
return "gpsLocation" return "gpsLocation"
Binary file not shown.
+2 -4
View File
@@ -18,7 +18,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"BootCount", {PERSISTENT, INT}}, {"BootCount", {PERSISTENT, INT}},
{"BluetoothAudioAddress", {PERSISTENT, STRING}}, {"BluetoothAudioAddress", {PERSISTENT, STRING}},
{"BluetoothAudioTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}}, {"BluetoothAudioTestActive", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}},
{"BluetoothDisconnectControllersOffroad", {PERSISTENT, BOOL, "0"}},
{"BluetoothEnabled", {PERSISTENT, BOOL, "0"}}, {"BluetoothEnabled", {PERSISTENT, BOOL, "0"}},
{"CalibrationParams", {PERSISTENT, BYTES}}, {"CalibrationParams", {PERSISTENT, BYTES}},
{"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}}, {"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}},
@@ -317,7 +316,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, "1", "1", 0, SETTINGS_SIMPLE}}, {"GalaxyMobileDefault", {PERSISTENT | DONT_LOG, BOOL, "0", "0", 0, SETTINGS_ADVANCED}},
{"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}},
@@ -464,7 +463,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LeadDepartingAlert", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"LeadDepartingAlert", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
{"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}}, {"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}},
{"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"LeadInfo", {PERSISTENT, BOOL, "0", "0", 3}}, {"LeadInfo", {PERSISTENT, BOOL, "1", "0", 3}},
{"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}}, {"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}},
{"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}}, {"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}},
{"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}}, {"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}},
@@ -609,7 +608,6 @@ 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,6 +852,7 @@ class CarController(CarControllerBase):
CAR.CHEVROLET_VOLT_CC, CAR.CHEVROLET_VOLT_CC,
CAR.CHEVROLET_MALIBU_CC, CAR.CHEVROLET_MALIBU_CC,
CAR.CHEVROLET_MALIBU_HYBRID_CC, CAR.CHEVROLET_MALIBU_HYBRID_CC,
CAR.BUICK_LACROSSE,
} }
if (self.CP.enableGasInterceptorDEPRECATED and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and if (self.CP.enableGasInterceptorDEPRECATED and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
+160 -4
View File
@@ -1,5 +1,7 @@
import copy import copy
import math import math
from datetime import UTC, datetime, timedelta
from collections.abc import Mapping
from cereal import custom from cereal import custom
from opendbc.can import CANDefine, CANParser from opendbc.can import CANDefine, CANParser
from opendbc.car import Bus, create_button_events, structs from opendbc.car import Bus, create_button_events, structs
@@ -8,12 +10,10 @@ from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.gps import get_car_gps_config from opendbc.car.gps import get_car_gps_config
from opendbc.car.interfaces import CarStateBase from opendbc.car.interfaces import CarStateBase
from opendbc.car.gm.values import ( from opendbc.car.gm.values import (
ALT_ACCS,
ASCM_INT, ASCM_INT,
CAMERA_ACC_CAR, CAMERA_ACC_CAR,
CAR, CAR,
CC_ONLY_CAR, CC_ONLY_CAR,
CC_REGEN_PADDLE_CAR,
DBC, DBC,
AccState, AccState,
CanBus, CanBus,
@@ -38,6 +38,112 @@ BUTTONS_DICT = {CruiseButtons.RES_ACCEL: ButtonType.accelCruise, CruiseButtons.D
HARD_BUTTONS_DICT = {CruiseButtons.RES_ACCEL: ButtonType.accelCruise, CruiseButtons.DECEL_SET: ButtonType.decelCruise} HARD_BUTTONS_DICT = {CruiseButtons.RES_ACCEL: ButtonType.accelCruise, CruiseButtons.DECEL_SET: ButtonType.decelCruise}
NORMAL_CRUISE_BUTTONS = (CruiseButtons.RES_ACCEL, CruiseButtons.DECEL_SET) NORMAL_CRUISE_BUTTONS = (CruiseButtons.RES_ACCEL, CruiseButtons.DECEL_SET)
# Optional ~10 Hz CT6 PPS GPS messages on the powertrain bus.
PPS_GPS_MESSAGES = (
"PPS_ElevHdSpd_FO",
"PPS_PosLat_FO",
"PPS_PosLong_FO",
"PPS_Time_FO",
"PPS_QualMetrics_FO",
)
# PPS_SigAcqTime_FO is omitted: its validity bit stays 1 even during valid fixes.
def pps_checksum_ok(data: bytes) -> bool:
"""Validate the 11-bit checksum used by the observed PPS frames."""
if len(data) < 2:
return False
received = ((data[-2] & 0x07) << 8) | data[-1]
expected = sum(data[:-2]) + (data[-2] >> 3) + 0x4C
return (expected & 0x7FF) == received
def decode_gm_pps_gps(values: Mapping[str, Mapping[str, float]], raw: Mapping[str, bytes],
timestamp_nanos: int) -> dict | None:
"""Decode one coherent PPS bundle into the existing car-GPS sample shape."""
if any(not pps_checksum_ok(raw.get(name, b"")) for name in PPS_GPS_MESSAGES):
return None
try:
pos_lat = values["PPS_PosLat_FO"]
pos_long = values["PPS_PosLong_FO"]
timestamp_values = values["PPS_Time_FO"]
quality = values["PPS_QualMetrics_FO"]
if (int(pos_lat.get("PPSLatV", 1)) != 0 or
int(pos_long.get("PPSLongV", 1)) != 0 or
int(quality.get("PPS2DAbsPosErrEstmtV", 1)) != 0 or
int(quality.get("PPSMdV", 1)) != 0 or
int(quality.get("PPSPstnDilPrcsV", 1)) != 0 or
int(timestamp_values.get("PPSTmdayV", 1)) != 0 or
int(timestamp_values.get("PPSCldrDayV", 1)) != 0 or
int(timestamp_values.get("PPSCldrYrV", 1)) != 0):
return None
# Reject mode 6 (dead reckoning only without GNSS).
if int(quality["PPSMd"]) == 6:
return None
latitude = float(pos_lat["PPSLat"]) / 3_600_000.0
longitude = float(pos_long["PPSLong"]) / 3_600_000.0
if not (math.isfinite(latitude) and math.isfinite(longitude) and
-90.0 <= latitude <= 90.0 and -180.0 <= longitude <= 180.0 and
(latitude != 0.0 or longitude != 0.0)):
return None
year = int(timestamp_values["PPSCldrYr"])
day_of_year = int(timestamp_values["PPSCldrDay"])
millis_of_day = int(timestamp_values["PPSTmday"])
if not 2014 <= year <= 2141 or day_of_year < 1 or not 0 <= millis_of_day < 86_400_000:
return None
timestamp = datetime(year, 1, 1, tzinfo=UTC) + timedelta(days=day_of_year - 1, milliseconds=millis_of_day)
if timestamp.year != year:
return None
elev = values["PPS_ElevHdSpd_FO"]
speed = float(elev["PPSVel"]) * CV.KPH_TO_MS
if int(elev.get("PPSVelV", 1)) != 0 or not math.isfinite(speed) or not 0.0 <= speed <= 200.0:
speed = 0.0
heading = float(elev["PPSHedng"])
if (int(elev.get("PPSHedngV", 1)) != 0 or
not math.isfinite(heading) or not 0.0 <= heading < 360.0):
heading = 0.0
altitude = float(elev["PPSElvtn"]) / 100.0
if int(elev.get("PPSElvtnV", 1)) != 0 or not math.isfinite(altitude):
altitude = 0.0
horizontal_accuracy = float(quality["PPS2DAbsPosErrEstmt"])
if not math.isfinite(horizontal_accuracy) or horizontal_accuracy < 0.0:
horizontal_accuracy = 0.0
vertical_accuracy = float(quality["PPS3DAbsPosErrEstmt"])
if int(quality.get("PPS3DAbsPosErrEstmtV", 1)) != 0 or not math.isfinite(vertical_accuracy) or vertical_accuracy < 0.0:
vertical_accuracy = 0.0
bearing_accuracy = float(quality["PPSAbsHdngErrEstmt"])
if int(quality.get("PPSAbsHdngErrEstmtV", 1)) != 0 or not math.isfinite(bearing_accuracy) or bearing_accuracy < 0.0:
bearing_accuracy = 180.0
except (KeyError, TypeError, ValueError, OverflowError, AttributeError):
return None
heading_rad = math.radians(heading)
return {
"timestamp_nanos": timestamp_nanos,
"latitude": latitude,
"longitude": longitude,
"altitude": altitude,
"speed": speed,
"bearingDeg": heading,
"horizontalAccuracy": horizontal_accuracy,
"unixTimestampMillis": round(timestamp.timestamp() * 1000),
"verticalAccuracy": vertical_accuracy,
"bearingAccuracyDeg": bearing_accuracy,
# Velocity error units are undocumented in DBC; omit conversion.
"speedAccuracy": 0.0,
"hasFix": True,
"satelliteCount": 0,
"vNED": [speed * math.cos(heading_rad), speed * math.sin(heading_rad), 0.0],
}
def get_hard_cruise_buttons(steering_button_msg: dict) -> int: def get_hard_cruise_buttons(steering_button_msg: dict) -> int:
return steering_button_msg.get("ACCButtonsHard", CruiseButtons.INIT) return steering_button_msg.get("ACCButtonsHard", CruiseButtons.INIT)
@@ -109,11 +215,15 @@ class CarState(CarStateBase):
self.car_gps_config = get_car_gps_config(CP) self.car_gps_config = get_car_gps_config(CP)
self.car_gps_supported = self.car_gps_config is not None self.car_gps_supported = self.car_gps_config is not None
self.car_gps = None self.car_gps = None
self.onstar_gps = None
self._car_gps_timestamp_nanos = 0 self._car_gps_timestamp_nanos = 0
self._prev_gps_lat = None self._prev_gps_lat = None
self._prev_gps_lon = None self._prev_gps_lon = None
self._last_gps_bearing = None self._last_gps_bearing = None
self.pps_gps = None
self._pps_gps_timestamp_nanos = 0
def _update_car_gps(self, cp, v_ego: float = 0.0) -> None: def _update_car_gps(self, cp, v_ego: float = 0.0) -> None:
if self.car_gps_config is None: if self.car_gps_config is None:
return return
@@ -148,12 +258,47 @@ class CarState(CarStateBase):
else: else:
self._prev_gps_lat = self._prev_gps_lon = None self._prev_gps_lat = self._prev_gps_lon = None
self.onstar_gps = gps
self.car_gps = gps self.car_gps = gps
self._car_gps_timestamp_nanos = timestamp_nanos self._car_gps_timestamp_nanos = timestamp_nanos
def get_car_gps(self): def _update_pps_gps(self, cp) -> None:
"""Decode a complete, checksum-valid PPS burst when one is available."""
timestamps = [max(cp.ts_nanos[name].values(), default=0) for name in PPS_GPS_MESSAGES]
if not all(timestamps):
return
timestamp_nanos = max(timestamps)
if timestamp_nanos <= self._pps_gps_timestamp_nanos:
return
if timestamp_nanos - min(timestamps) > 100_000_000:
return
vl = cp.vl
try:
first_id = int(vl["PPS_ElevHdSpd_FO"]["PPSElvHedngSpdBrstID"])
if not (first_id == int(vl["PPS_PosLat_FO"]["PPSLatBrstID"]) ==
int(vl["PPS_PosLong_FO"]["PPSLongBrstID"]) ==
int(vl["PPS_Time_FO"]["PPSTmBrstID"]) ==
int(vl["PPS_QualMetrics_FO"]["PPSPosQltyMtcBrstID"])):
return
except (KeyError, ValueError, TypeError, OverflowError):
return
values = {name: cp.vl[name] for name in PPS_GPS_MESSAGES}
raw = {name: cp.vl_raw[name] for name in PPS_GPS_MESSAGES}
self.pps_gps = decode_gm_pps_gps(values, raw, timestamp_nanos)
self._pps_gps_timestamp_nanos = timestamp_nanos
def get_car_gps(self) -> dict | None:
return self.car_gps return self.car_gps
def get_car_gps_sources(self) -> dict[str, dict | None]:
return {
"pps": self.pps_gps,
"onstar": self.onstar_gps,
}
def update_button_enable(self, buttonEvents: list[structs.CarState.ButtonEvent]): def update_button_enable(self, buttonEvents: list[structs.CarState.ButtonEvent]):
if not self.CP.pcmCruise: if not self.CP.pcmCruise:
for b in buttonEvents: for b in buttonEvents:
@@ -239,6 +384,9 @@ class CarState(CarStateBase):
abs(pt_cp.vl["EBCMWheelSpdRear"]["RRWheelSpd"]) <= STANDSTILL_THRESHOLD abs(pt_cp.vl["EBCMWheelSpdRear"]["RRWheelSpd"]) <= STANDSTILL_THRESHOLD
self._update_car_gps(pt_cp, ret.vEgo) self._update_car_gps(pt_cp, ret.vEgo)
pps_cp = can_parsers.get(Bus.adas)
if pps_cp is not None:
self._update_pps_gps(pps_cp)
if pt_cp.vl["ECMPRDNL2"]["ManualMode"] == 1: if pt_cp.vl["ECMPRDNL2"]["ManualMode"] == 1:
ret.gearShifter = self.parse_gear_shifter("T") ret.gearShifter = self.parse_gear_shifter("T")
@@ -588,8 +736,16 @@ class CarState(CarStateBase):
("ASCMLKASteeringCmd", 0), ("ASCMLKASteeringCmd", 0),
] ]
return { parsers = {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], pt_messages, CanBus.POWERTRAIN), Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], pt_messages, CanBus.POWERTRAIN),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], cam_messages, CanBus.CAMERA), Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], cam_messages, CanBus.CAMERA),
Bus.loopback: CANParser(DBC[CP.carFingerprint][Bus.pt], loopback_messages, CanBus.LOOPBACK), Bus.loopback: CANParser(DBC[CP.carFingerprint][Bus.pt], loopback_messages, CanBus.LOOPBACK),
} }
if getattr(CP, "brand", None) == "gm":
# Optional CT6 PPS parser on Bus.adas; non-PPS vehicles remain CAN-valid.
parsers[Bus.adas] = CANParser(
"cadillac_ct6_object",
[(name, 0) for name in PPS_GPS_MESSAGES],
CanBus.POWERTRAIN,
)
return parsers
+2 -2
View File
@@ -408,7 +408,7 @@ class CarInterface(CarInterfaceBase):
ret.steerActuatorDelay = 0.1 # Default delay, not measured yet ret.steerActuatorDelay = 0.1 # Default delay, not measured yet
ret.steerLimitTimer = 0.4 ret.steerLimitTimer = 0.4
ret.radarTimeStepDEPRECATED = 0.15 if candidate == CAR.BUICK_LACROSSE else 0.0667 ret.radarTimeStepDEPRECATED = 0.0667 # GM radar runs at 15Hz instead of the standard 20Hz
ret.longitudinalActuatorDelay = 0.5 # large delay to initially start braking ret.longitudinalActuatorDelay = 0.5 # large delay to initially start braking
if candidate in ( if candidate in (
@@ -440,7 +440,7 @@ class CarInterface(CarInterfaceBase):
elif candidate in (CAR.BUICK_LACROSSE, CAR.BUICK_LACROSSE_ASCM, CAR.BUICK_LACROSSE_ASCM_19US): elif candidate in (CAR.BUICK_LACROSSE, CAR.BUICK_LACROSSE_ASCM, CAR.BUICK_LACROSSE_ASCM_19US):
CarInterfaceBase.configure_torque_tune(CAR.BUICK_LACROSSE, ret.lateralTuning) CarInterfaceBase.configure_torque_tune(CAR.BUICK_LACROSSE, ret.lateralTuning)
if candidate == CAR.BUICK_LACROSSE_ASCM_19US: if candidate == CAR.BUICK_LACROSSE_ASCM_19US:
ret.minSteerSpeed = 28 * CV.MPH_TO_MS ret.minSteerSpeed = 27 * CV.MPH_TO_MS
elif candidate == CAR.CADILLAC_ESCALADE: elif candidate == CAR.CADILLAC_ESCALADE:
ret.minEnableSpeed = -1. # engage speed is decided by pcm ret.minEnableSpeed = -1. # engage speed is decided by pcm
+149 -36
View File
@@ -1,5 +1,6 @@
import pytest import pytest
import numpy as np import numpy as np
from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from parameterized import parameterized from parameterized import parameterized
@@ -8,7 +9,14 @@ from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, DT_CTRL, structs from opendbc.car import Bus, DT_CTRL, structs
from opendbc.car.car_helpers import interfaces from opendbc.car.car_helpers import interfaces
from opendbc.car.gm import gmcan from opendbc.car.gm import gmcan
from opendbc.car.gm.carstate import CarState as GMCarState, get_hard_cruise_buttons, update_auto_hold_drive_timers from opendbc.car.gm.carstate import (
CarState as GMCarState,
PPS_GPS_MESSAGES,
decode_gm_pps_gps,
get_hard_cruise_buttons,
pps_checksum_ok,
update_auto_hold_drive_timers,
)
from opendbc.car.gm.carcontroller import ( from opendbc.car.gm.carcontroller import (
VisualAlert, VisualAlert,
get_acc_dashboard_always_one, get_acc_dashboard_always_one,
@@ -95,6 +103,146 @@ class TestBoltGps:
assert gps["verticalAccuracy"] == 10.0 assert gps["verticalAccuracy"] == 10.0
assert gps["speedAccuracy"] == 0.5 assert gps["speedAccuracy"] == 0.5
class TestPpsGps:
_frames = [
(0x260, bytes.fromhex("10ddac000d831277"), 0),
(0x261, bytes.fromhex("08386fce09ca"), 0),
(0x262, bytes.fromhex("6d98820341de"), 0),
(0x264, bytes.fromhex("0018f90578b5eaac"), 0),
(0x265, bytes.fromhex("1a0000800a0258fd"), 0),
]
def test_observed_bundle_checksum_and_conversion(self):
parser = CANParser("cadillac_ct6_object", [(name, 0) for name in PPS_GPS_MESSAGES], 0)
parser.update([(1_000_000_000, self._frames)])
assert all(pps_checksum_ok(parser.vl_raw[name]) for name in PPS_GPS_MESSAGES)
gps = decode_gm_pps_gps(
{name: parser.vl[name] for name in PPS_GPS_MESSAGES},
{name: parser.vl_raw[name] for name in PPS_GPS_MESSAGES},
1_000_000_000,
)
assert gps is not None
assert gps["hasFix"]
assert gps["latitude"] == pytest.approx(38.3101, abs=1e-4)
assert gps["longitude"] == pytest.approx(-85.7701, abs=1e-4)
assert gps["altitude"] == pytest.approx(106.9)
assert gps["bearingDeg"] == pytest.approx(56.748)
assert gps["horizontalAccuracy"] == pytest.approx(1.0)
assert gps["unixTimestampMillis"] == 1788655668655
@pytest.fixture
def bundle(self):
parser = CANParser("cadillac_ct6_object", [(name, 0) for name in PPS_GPS_MESSAGES], 0)
parser.update([(1_000_000_000, self._frames)])
return ({name: dict(parser.vl[name]) for name in PPS_GPS_MESSAGES},
{name: parser.vl_raw[name] for name in PPS_GPS_MESSAGES})
@pytest.mark.parametrize("year,day,date", [
(2025, 1, "2025-01-01"), (2025, 365, "2025-12-31"), (2025, 366, None),
(2024, 366, "2024-12-31"), (2024, 367, None), (2025, 0, None),
])
def test_one_based_day_of_year(self, bundle, year, day, date):
values, raw = bundle
values["PPS_Time_FO"].update(PPSCldrYr=year, PPSCldrDay=day, PPSTmday=1234)
gps = decode_gm_pps_gps(values, raw, 1_000_000_000)
if date is None:
assert gps is None
else:
expected = int(datetime.fromisoformat(date).replace(tzinfo=UTC).timestamp() * 1000) + 1234
assert gps["unixTimestampMillis"] == expected
@pytest.mark.parametrize("bad_data", [b"", b"\x01"])
def test_checksum_short_input(self, bad_data):
assert not pps_checksum_ok(bad_data)
@pytest.mark.parametrize("lat,lon,valid", [(0.0, 10.0 * 3_600_000, True), (10.0 * 3_600_000, 0.0, True), (0.0, 0.0, False)])
def test_coordinate_axes(self, bundle, lat, lon, valid):
values, raw = bundle
values["PPS_PosLat_FO"]["PPSLat"] = lat
values["PPS_PosLong_FO"]["PPSLong"] = lon
gps = decode_gm_pps_gps(values, raw, 1_000_000_000)
if valid:
assert gps is not None
assert gps["hasFix"]
else:
assert gps is None
def test_get_car_gps_sources_shape(self):
cs = GMCarState.__new__(GMCarState)
cs.pps_gps = {"hasFix": True}
cs.onstar_gps = None
sources = cs.get_car_gps_sources()
assert sources == {"pps": {"hasFix": True}, "onstar": None}
@pytest.mark.parametrize("message,signal,value", [
("PPS_PosLat_FO", "PPSLatV", 1),
("PPS_PosLong_FO", "PPSLongV", 1),
("PPS_QualMetrics_FO", "PPS2DAbsPosErrEstmtV", 1),
("PPS_PosLat_FO", "PPSLat", float("nan")),
("PPS_PosLong_FO", "PPSLong", 181 * 3_600_000),
("PPS_QualMetrics_FO", "PPSMd", 6),
("PPS_Time_FO", "PPSTmdayV", 1),
])
def test_unusable_position_rejected(self, bundle, message, signal, value):
values, raw = bundle
values[message][signal] = value
assert decode_gm_pps_gps(values, raw, 1_000_000_000) is None
@pytest.mark.parametrize("invalidity", ["checksum", "position-validity"])
def test_burst_cache_and_explicit_invalidation(self, invalidity):
parser = CANParser("cadillac_ct6_object", [(name, 0) for name in PPS_GPS_MESSAGES], 0)
cs = GMCarState.__new__(GMCarState)
cs.pps_gps = None
cs._pps_gps_timestamp_nanos = 0
parser.update([(1_000_000_000, self._frames[:-1])])
cs._update_pps_gps(parser)
assert cs.pps_gps is None # Incomplete startup burst.
parser.update([(1_000_000_000, self._frames[-1:])])
cs._update_pps_gps(parser)
good = cs.pps_gps
assert good is not None
# No complete new burst: keep its original timestamp for freshness.
parser.update([(2_000_000_000, self._frames[:1])])
cs._update_pps_gps(parser)
assert cs.pps_gps is good
parser.update([(2_100_000_000, self._frames)])
parser.vl["PPS_PosLat_FO"]["PPSLatBrstID"] = int(parser.vl["PPS_PosLat_FO"]["PPSLatBrstID"]) ^ 1
cs._update_pps_gps(parser)
assert cs.pps_gps is good # A mismatched burst must not refresh the fix.
bad_frames = ([(addr, data[:-1] + bytes([data[-1] ^ 1]), bus) for addr, data, bus in self._frames]
if invalidity == "checksum" else self._frames)
parser.update([(3_000_000_000, bad_frames)])
if invalidity == "position-validity":
parser.vl["PPS_PosLat_FO"]["PPSLatV"] = 1
cs._update_pps_gps(parser)
assert cs.pps_gps is None
parser.update([(4_000_000_000, self._frames)])
cs._update_pps_gps(parser)
assert cs.pps_gps is not None
@pytest.mark.parametrize("speed_bad,heading_bad,elevation_bad,vertical_bad,bearing_bad", [
(0, 0, 0, 0, 0), (1, 0, 0, 0, 0), (0, 1, 0, 0, 0), (0, 0, 1, 0, 0),
(0, 0, 0, 1, 0), (0, 0, 0, 0, 1), (1, 1, 1, 1, 1),
], ids=["valid", "speed", "heading", "elevation", "vertical-accuracy", "bearing-accuracy", "all-invalid"])
def test_optional_field_fallbacks(self, bundle, speed_bad, heading_bad, elevation_bad, vertical_bad, bearing_bad):
values, raw = bundle
values["PPS_ElevHdSpd_FO"].update(PPSVel=36, PPSVelV=speed_bad, PPSHedng=90, PPSHedngV=heading_bad,
PPSElvtn=12345, PPSElvtnV=elevation_bad)
values["PPS_QualMetrics_FO"].update(PPS2DAbsPosErrEstmt=3.2, PPS3DAbsPosErrEstmt=4.5, PPS3DAbsPosErrEstmtV=vertical_bad,
PPSAbsHdngErrEstmt=6.0, PPSAbsHdngErrEstmtV=bearing_bad)
gps = decode_gm_pps_gps(values, raw, 1_000_000_000)
assert gps is not None
assert gps["hasFix"]
assert gps["speed"] == pytest.approx(0.0 if speed_bad else 10.0)
assert gps["bearingDeg"] == (0 if heading_bad else 90)
assert gps["altitude"] == pytest.approx(0.0 if elevation_bad else 123.45)
assert gps["vNED"] == pytest.approx([gps["speed"], 0, 0] if heading_bad else [0, gps["speed"], 0])
assert gps["horizontalAccuracy"] == pytest.approx(3.2)
assert gps["verticalAccuracy"] == pytest.approx(0.0 if vertical_bad else 4.5)
assert gps["bearingAccuracyDeg"] == pytest.approx(180.0 if bearing_bad else 6.0)
def test_bolt_gps_heading_and_speed_derivation(self): def test_bolt_gps_heading_and_speed_derivation(self):
cp = SimpleNamespace( cp = SimpleNamespace(
brand="gm", brand="gm",
@@ -205,33 +353,6 @@ class TestBoltGps:
class TestGMInterface: class TestGMInterface:
def test_lacrosse_obd_and_ascm_integrations_remain_separate(self):
obd_params = interfaces[CAR.BUICK_LACROSSE].get_params(
CAR.BUICK_LACROSSE,
_empty_fingerprint(),
[],
alpha_long=False,
is_release=False,
docs=False,
starpilot_toggles=_test_starpilot_toggles(),
)
ascm_params = interfaces[CAR.BUICK_LACROSSE_ASCM].get_params(
CAR.BUICK_LACROSSE_ASCM,
_empty_fingerprint(),
[],
alpha_long=False,
is_release=False,
docs=False,
starpilot_toggles=_test_starpilot_toggles(),
)
assert obd_params.networkLocation == structs.CarParams.NetworkLocation.gateway
assert obd_params.openpilotLongitudinalControl
assert obd_params.radarTimeStepDEPRECATED == pytest.approx(0.15)
assert ascm_params.networkLocation == structs.CarParams.NetworkLocation.fwdCamera
assert not ascm_params.openpilotLongitudinalControl
assert ascm_params.radarTimeStepDEPRECATED == pytest.approx(0.0667)
@parameterized.expand([ @parameterized.expand([
CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2017,
CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2018_2021,
@@ -318,14 +439,6 @@ class TestGMInterface:
assert car_params.minSteerSpeed == pytest.approx(7 * CV.MPH_TO_MS) assert car_params.minSteerSpeed == pytest.approx(7 * CV.MPH_TO_MS)
def test_lacrosse_2019_ascm_min_steer_speed_is_28_mph(self):
car_model = CAR.BUICK_LACROSSE_ASCM_19US
CarInterface = interfaces[car_model]
car_params = CarInterface.get_params(car_model, _empty_fingerprint(), [], alpha_long=False, is_release=False, docs=False,
starpilot_toggles=_test_starpilot_toggles())
assert car_params.minSteerSpeed == pytest.approx(28 * CV.MPH_TO_MS)
@parameterized.expand([ @parameterized.expand([
("interceptor", True), ("interceptor", True),
("ascm_int", False), ("ascm_int", False),
+21 -2
View File
@@ -6,8 +6,10 @@ from collections.abc import Callable, Mapping
from typing import Any from typing import Any
from opendbc.car.common.conversions import Conversions as CV from opendbc.car.common.conversions import Conversions as CV
from opendbc.can.dbc import DBC as DBC_FILE
from opendbc.car import Bus
from opendbc.car.ford.values import CAR as FORD_CAR from opendbc.car.ford.values import CAR as FORD_CAR
from opendbc.car.gm.values import CAR as GM_CAR from opendbc.car.gm.values import CAR as GM_CAR, DBC as GM_DBC
CarGpsSample = dict[str, Any] CarGpsSample = dict[str, Any]
@@ -158,8 +160,25 @@ CAR_GPS_CONFIGS: dict[str, CarGpsConfig] = {
def get_car_gps_config(CP) -> CarGpsConfig | None: def get_car_gps_config(CP) -> CarGpsConfig | None:
cp_brand = getattr(CP, "brand", None)
config = CAR_GPS_CONFIGS.get(CP.carFingerprint) config = CAR_GPS_CONFIGS.get(CP.carFingerprint)
return config if config is not None and config.brand == CP.brand else None if config is not None and config.brand == cp_brand:
return config
# Enable OnStar GPS for GM cars whose powertrain DBC defines it.
if cp_brand == "gm":
try:
dbc_name = GM_DBC[CP.carFingerprint][Bus.pt]
if "TCICOnStarGPSPosition" in DBC_FILE(dbc_name).name_to_msg:
return CarGpsConfig(
brand="gm",
messages=CHEVROLET_BOLT_GPS_MESSAGES,
decoder=parse_chevrolet_bolt_can_gps,
)
except (KeyError, OSError, TypeError, RuntimeError):
pass
return None
def car_gps_available(CP) -> bool: def car_gps_available(CP) -> bool:
@@ -860,9 +860,7 @@ class CarController(CarControllerBase):
lka_steering = self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING lka_steering = self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING
longitudinal_active = bool(self.long_active_ecu and getattr(CC, "longActive", False)) longitudinal_active = bool(self.long_active_ecu and getattr(CC, "longActive", False))
lfa_status_cars = (CAR.HYUNDAI_IONIQ_6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) lfa_longitudinal_active = longitudinal_active if self.CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else self.CP.openpilotLongitudinalControl
lfa_longitudinal_active = self.CP.openpilotLongitudinalControl \
if self.CP.carFingerprint in lfa_status_cars else longitudinal_active
lka_steering_long = lka_steering and lfa_longitudinal_active lka_steering_long = lka_steering and lfa_longitudinal_active
ccnc_non_hda2 = self.CP.flags & HyundaiFlags.CCNC and not lka_steering ccnc_non_hda2 = self.CP.flags & HyundaiFlags.CCNC and not lka_steering
use_egmp_dynamic_long_tuning = egmp_dynamic_longitudinal_tuning(self.CP) and self.long_active_ecu and \ use_egmp_dynamic_long_tuning = egmp_dynamic_longitudinal_tuning(self.CP) and self.long_active_ecu and \
@@ -892,8 +890,7 @@ class CarController(CarControllerBase):
if angle_lkas_alt: if angle_lkas_alt:
steering_msg_active = bool(steering_msg_active and drive_gear) steering_msg_active = bool(steering_msg_active and drive_gear)
angle_lkas_alt_standstill_handoff = bool(getattr(CS.out, "standstill", False) and not CC.latActive) angle_lkas_alt_standstill_handoff = bool(getattr(CS.out, "standstill", False) and not CC.latActive)
forward_stock_lkas = (self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR or forward_stock_lkas = self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR and angle_lkas_alt and (
self.CP.carFingerprint == CAR.KIA_SPORTAGE_HEV_2026) and angle_lkas_alt and (
angle_lkas_alt_standstill_handoff or not (drive_gear and (CC.latActive or CC.enabled)) angle_lkas_alt_standstill_handoff or not (drive_gear and (CC.latActive or CC.enabled))
) )
preserve_stock_lfa_status = preserve_stock_canfd_lfa_status(self.CP.carFingerprint) preserve_stock_lfa_status = preserve_stock_canfd_lfa_status(self.CP.carFingerprint)
@@ -2484,11 +2484,10 @@ class TestHyundaiFingerprint:
CP = CarParams.new_message() CP = CarParams.new_message()
CP.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN CP.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEERING) CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEERING)
CP.openpilotLongitudinalControl = True CP.openpilotLongitudinalControl = False
controller = CarController(DBC[CP.carFingerprint], CP) controller = CarController(DBC[CP.carFingerprint], CP)
controller.frame = 1 controller.frame = 1
controller.long_active_ecu = True
can_bus = CanBus(CP) can_bus = CanBus(CP)
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS", 0)], can_bus.ACAN) parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS", 0)], can_bus.ACAN)
stock_lkas = { stock_lkas = {
@@ -2530,6 +2529,7 @@ class TestHyundaiFingerprint:
assert parser.vl["LKAS"]["STEER_MODE"] == 0 assert parser.vl["LKAS"]["STEER_MODE"] == 0
assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0 assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0
CP.openpilotLongitudinalControl = True
lfa_parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LFA", 0)], can_bus.ECAN) lfa_parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LFA", 0)], can_bus.ECAN)
lfa_msgs = hyundaicanfd.create_steering_messages(controller.packer, CP, can_bus, True, True, 0, 0.0) lfa_msgs = hyundaicanfd.create_steering_messages(controller.packer, CP, can_bus, True, True, 0, 0.0)
assert [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in lfa_msgs] == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)] assert [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in lfa_msgs] == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)]
@@ -2537,12 +2537,13 @@ class TestHyundaiFingerprint:
assert lfa_parser.can_valid assert lfa_parser.can_valid
assert lfa_parser.vl["LFA"]["DAMP_FACTOR"] == 100 assert lfa_parser.vl["LFA"]["DAMP_FACTOR"] == 100
controller.long_active_ecu = True
cc.longActive = False cc.longActive = False
inactive_msgs = controller.create_canfd_msgs(0, True, 0.44, 0.0, 0.0, 0.0, False, inactive_msgs = controller.create_canfd_msgs(0, True, 0.44, 0.0, 0.0, 0.0, False,
cc.hudControl, cs, cc, get_test_toggles(), lka_icon=2, lfa_icon=2) cc.hudControl, cs, cc, get_test_toggles(), lka_icon=2, lfa_icon=2)
steering_names = [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in inactive_msgs steering_names = [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in inactive_msgs
if controller.packer.dbc.addr_to_msg[addr].name in ("LFA", "LKAS")] if controller.packer.dbc.addr_to_msg[addr].name in ("LFA", "LKAS")]
assert steering_names == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)] assert steering_names == [("LKAS", can_bus.ACAN)]
controller.frame = 1 controller.frame = 1
cc.longActive = True cc.longActive = True
@@ -2707,7 +2708,7 @@ class TestHyundaiFingerprint:
assert len([msg for msg in msgs if msg[0] == 0x110]) == expected_lkas_msgs assert len([msg for msg in msgs if msg[0] == 0x110]) == expected_lkas_msgs
@pytest.mark.parametrize("standstill", [False, True]) @pytest.mark.parametrize("standstill", [False, True])
def test_sportage_angle_lkas_alt_forwards_stock_status_when_inactive(self, standstill): def test_sportage_angle_lkas_alt_keeps_inactive_status_in_drive(self, standstill):
CP = CarParams.new_message() CP = CarParams.new_message()
CP.carFingerprint = CAR.KIA_SPORTAGE_HEV_2026 CP.carFingerprint = CAR.KIA_SPORTAGE_HEV_2026
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ANGLE_STEERING | CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ANGLE_STEERING |
@@ -2715,16 +2716,60 @@ class TestHyundaiFingerprint:
CP.openpilotLongitudinalControl = False CP.openpilotLongitudinalControl = False
controller = CarController(DBC[CP.carFingerprint], CP) controller = CarController(DBC[CP.carFingerprint], CP)
can_bus = CanBus(CP)
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS_ALT", 0)], can_bus.ACAN)
stock_lkas = {
"CHECKSUM": 1234,
"COUNTER": 42,
"LKA_OptUsmSta": 2,
"LKA_MODE": 2,
"LKA_RcgSta": 3,
"LKA_AVAILABLE": 3,
"LKA_LHLnWrnSta": 3,
"LKA_RHLnWrnSta": 3,
"LKA_WARNING": 1,
"LKA_HndsoffSnd": 1,
"LKA_StrSnd": 1,
"LKA_SysIndReq": 4,
"LKA_ICON": 2,
"FCA_SYSWARN": 1,
"StrTqReqVal": 17,
"TORQUE_REQUEST": 17,
"ActToiSta": 3,
"STEER_REQ": 1,
"ToiFltSta": 3,
"LFA_BUTTON": 1,
"LKA_SysWrn": 15,
"LKA_ASSIST": 1,
"Damping_Gain": 0,
"STEER_MODE": 5,
"NEW_SIGNAL_2": 0,
"LKAS_ANGLE_ACTIVE": 2,
"LKA_UsmMod": 3,
"HAS_LANE_SAFETY": 1,
"ADAS_StrAnglReqVal": 12.3,
"ADAS_ACIAnglTqRedcGainVal": 0.42,
"DAMP_FACTOR": 0,
}
cc = SimpleNamespace(enabled=False, latActive=False, cc = SimpleNamespace(enabled=False, latActive=False,
actuators=SimpleNamespace(longControlState=LongCtrlState.off), actuators=SimpleNamespace(longControlState=LongCtrlState.off),
leftBlinker=False, rightBlinker=False, hudControl=SimpleNamespace()) leftBlinker=False, rightBlinker=False, hudControl=SimpleNamespace())
cs = SimpleNamespace(stock_lfa_msg=None, stock_lkas_msg={}, cs = SimpleNamespace(stock_lfa_msg=None, stock_lkas_msg=stock_lkas,
out=SimpleNamespace(standstill=standstill, steeringAngleDeg=0.0, out=SimpleNamespace(standstill=standstill, steeringAngleDeg=0.0,
gearShifter=structs.CarState.GearShifter.drive)) gearShifter=structs.CarState.GearShifter.drive))
msgs = controller.create_canfd_msgs(0, False, 0.0, 0.0, 0.0, 0.0, False, cc.hudControl, cs, cc, msgs = controller.create_canfd_msgs(0, False, 0.0, 0.0, 0.0, 0.0, False, cc.hudControl, cs, cc,
get_test_toggles(), lka_icon=1, lfa_icon=1) get_test_toggles(), lka_icon=1, lfa_icon=1)
assert not [msg for msg in msgs if msg[0] in (0x110, 0x12A)] lkas_msgs = [msg for msg in msgs if msg[0] == 0x110]
assert len(lkas_msgs) == 1
parser.update([(1, lkas_msgs)])
assert parser.can_valid
assert parser.vl["LKAS_ALT"]["LKA_StrSnd"] == 2
assert parser.vl["LKAS_ALT"]["LKA_SysIndReq"] == 1
assert parser.vl["LKAS_ALT"]["LKA_RcgSta"] == 0
assert parser.vl["LKAS_ALT"]["LKA_AVAILABLE"] == 0
assert parser.vl["LKAS_ALT"]["LKAS_ANGLE_ACTIVE"] == 1
def test_ev9_inactive_angle_steering_does_not_suppress_stock_lfa(self): def test_ev9_inactive_angle_steering_does_not_suppress_stock_lfa(self):
CP = CarParams.new_message() CP = CarParams.new_message()
@@ -77,7 +77,7 @@ class CarController(CarControllerBase):
self.angle_bus = CanBus.angle_for_cp(CP) self.angle_bus = CanBus.angle_for_cp(CP)
self.status_bus = CanBus.camera if CP.flags & SubaruFlags.D_PLATFORM_CAMERA else CanBus.main self.status_bus = CanBus.camera if CP.flags & SubaruFlags.D_PLATFORM_CAMERA else CanBus.main
if CP.flags & SubaruFlags.LKAS_ANGLE and CP.carFingerprint != CAR.SUBARU_OUTBACK_2023: if CP.flags & SubaruFlags.LKAS_ANGLE:
self.VM = VehicleModel(get_safety_CP()) self.VM = VehicleModel(get_safety_CP())
self.prev_close_distance = 0 self.prev_close_distance = 0
@@ -332,7 +332,7 @@ class CarController(CarControllerBase):
self.apply_steer_last = CS.out.steeringAngleDeg self.apply_steer_last = CS.out.steeringAngleDeg
steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lkas_active else CC.actuators.steeringAngleDeg steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lkas_active else CC.actuators.steeringAngleDeg
if self.CP.carFingerprint in (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): if self.CP.carFingerprint == CAR.SUBARU_ASCENT_2023:
apply_steer = apply_std_steer_angle_limits( apply_steer = apply_std_steer_angle_limits(
steer_target, steer_target,
self.apply_steer_last, self.apply_steer_last,
+1 -1
View File
@@ -42,7 +42,7 @@ class CarInterface(CarInterfaceBase):
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value
if candidate in SUBARU_STOP_START_CARS: if candidate in SUBARU_STOP_START_CARS:
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value
if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023):
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value
ret.steerLimitTimer = 0.4 ret.steerLimitTimer = 0.4
@@ -244,7 +244,7 @@ def test_outback_2023_uses_d_platform_bus_layout():
assert CP.flags & SubaruFlags.D_PLATFORM assert CP.flags & SubaruFlags.D_PLATFORM
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS)
assert CanBus.main_for_cp(CP) == CanBus.alt assert CanBus.main_for_cp(CP) == CanBus.alt
assert CanBus.angle_for_cp(CP) == CanBus.main assert CanBus.angle_for_cp(CP) == CanBus.main
assert parsers[Bus.pt].bus == CanBus.alt assert parsers[Bus.pt].bus == CanBus.alt
@@ -622,9 +622,8 @@ def test_angle_controller_blocks_low_speed_mads_engagement():
assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1 assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1
@pytest.mark.parametrize("platform", (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023)) def test_ascent_angle_controller_uses_fixed_angle_rate_limits():
def test_angle_controller_uses_fixed_angle_rate_limits(platform): CP = CarInterface.get_non_essential_params(CAR.SUBARU_ASCENT_2023)
CP = CarInterface.get_non_essential_params(platform)
controller = CarController({}, CP) controller = CarController({}, CP)
CC = SimpleNamespace(enabled=True, latActive=True, actuators=SimpleNamespace(steeringAngleDeg=-14.88)) CC = SimpleNamespace(enabled=True, latActive=True, actuators=SimpleNamespace(steeringAngleDeg=-14.88))
CS = SimpleNamespace(out=SimpleNamespace( CS = SimpleNamespace(out=SimpleNamespace(
@@ -417,18 +417,6 @@ class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, Test
return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle}) return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle})
class TestSubaruDPlatformFixedAngleSafety(TestSubaruDPlatformAngleSafety):
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \
SubaruSafetyFlags.FIXED_ANGLE_LIMITS
STEER_ANGLE_MAX = 545
ANGLE_RATE_BP = [0., 5., 35.]
ANGLE_RATE_UP = [5., .8, .15]
ANGLE_RATE_DOWN = [5., .8, .15]
def test_rt_limits(self):
raise unittest.SkipTest("Breakpoint angle limits do not enforce a real-time message frequency")
class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety): class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety):
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \ FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \
SubaruSafetyFlags.STOP_START_BUTTON SubaruSafetyFlags.STOP_START_BUTTON
+16 -6
View File
@@ -284,22 +284,32 @@ ensure_host_python_extensions() {
} }
sync_host_generated_headers() { sync_host_generated_headers() {
local capnpc="${ROOT_DIR}/.venv/bin/capnpc" local capnp_bin=""
local capnpc_cpp local candidate=""
capnpc_cpp="$(find "${ROOT_DIR}/.venv/lib" -path '*/capnproto/install/bin/capnpc-c++' -type f -print -quit)" for candidate in "${HOST_VENV}"/lib/python*/site-packages/capnproto/install/bin; do
if [[ ! -x "${capnpc}" || ! -x "${capnpc_cpp}" ]]; then if [[ -x "${candidate}/capnpc" ]]; then
capnp_bin="${candidate}"
break
fi
done
local capnpc_cmd="capnpc"
if [[ -n "${capnp_bin}" ]]; then
capnpc_cmd="${capnp_bin}/capnpc"
export PATH="${capnp_bin}:${PATH}"
elif ! command -v capnpc >/dev/null 2>&1; 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_cmd}" --src-prefix=cereal \
cereal/log.capnp \ cereal/log.capnp \
cereal/car.capnp \ cereal/car.capnp \
cereal/legacy.capnp \ cereal/legacy.capnp \
cereal/custom.capnp \ cereal/custom.capnp \
-o "${capnpc_cpp}:cereal/gen/cpp/" -o c++:cereal/gen/cpp/
) )
} }
+4
View File
@@ -69,6 +69,10 @@ def build_compile_env(*, supercombo: bool = False) -> dict[str, str]:
int(str(env.get(key)), 0) int(str(env.get(key)), 0)
except (TypeError, ValueError): except (TypeError, ValueError):
env[key] = default env[key] = default
if supercombo:
# Unified supercombo artifacts must use upstream compile defaults. The
# legacy QCOM tuning causes a reproducible HCQ timeline failure here.
env.pop("QCOM_PRIORITY", None)
return env return env
+94 -30
View File
@@ -43,6 +43,8 @@ REDNECK_DECREASE_LOOKAHEAD_POINTS = 10
SLC_SOURCE_NONE = "None" SLC_SOURCE_NONE = "None"
EventName = log.OnroadEvent.EventName EventName = log.OnroadEvent.EventName
GM_GPS_SOURCES = (("device", 2.0), ("pps", 1.0), ("onstar", 2.5))
# forward # forward
carlog.addHandler(ForwardingHandler(cloudlog)) carlog.addHandler(ForwardingHandler(cloudlog))
@@ -76,6 +78,22 @@ def can_comm_callbacks(logcan: messaging.SubSocket, sendcan: messaging.PubSocket
return can_recv, can_send return can_recv, can_send
def _gps_sample_is_healthy(sample: dict) -> bool:
if not sample.get("hasFix", False):
return False
try:
latitude = float(sample["latitude"])
longitude = float(sample["longitude"])
return (math.isfinite(latitude) and math.isfinite(longitude) and
-90.0 <= latitude <= 90.0 and -180.0 <= longitude <= 180.0 and
(latitude != 0.0 or longitude != 0.0) and
math.isfinite(float(sample["altitude"])) and
math.isfinite(float(sample["speed"])) and
math.isfinite(float(sample["bearingDeg"])))
except (KeyError, TypeError, ValueError):
return False
class Car: class Car:
CI: CarInterfaceBase CI: CarInterfaceBase
RI: RadarInterfaceBase RI: RadarInterfaceBase
@@ -85,7 +103,9 @@ class Car:
def __init__(self, CI=None, RI=None) -> None: def __init__(self, CI=None, RI=None) -> None:
self.can_sock = messaging.sub_sock('can', timeout=20) self.can_sock = messaging.sub_sock('can', timeout=20)
self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents', 'radarState', 'longitudinalPlan']) self.sm = messaging.SubMaster([
'pandaStates', 'carControl', 'onroadEvents', 'radarState', 'longitudinalPlan', 'gpsLocation',
])
self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks']) self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks'])
self.gps_pm = None self.gps_pm = None
@@ -93,6 +113,8 @@ class Car:
self._last_car_gps_timestamp_nanos = 0 self._last_car_gps_timestamp_nanos = 0
self._last_car_gps_received_monotonic = 0.0 self._last_car_gps_received_monotonic = 0.0
self._last_car_gps_publish_monotonic = 0.0 self._last_car_gps_publish_monotonic = 0.0
self._gm_gps = dict.fromkeys(source for source, _ in GM_GPS_SOURCES)
self._gm_gps_had_fix = False
self.CC_prev = car.CarControl.new_message() self.CC_prev = car.CarControl.new_message()
self.CS_prev = car.CarState.new_message() self.CS_prev = car.CarState.new_message()
@@ -141,8 +163,9 @@ class Car:
self.RI = RI self.RI = RI
car_gps_supported = bool(getattr(self.CI.CS, 'car_gps_supported', False)) car_gps_supported = bool(getattr(self.CI.CS, 'car_gps_supported', False))
self.gm_gps_supported = self.CP.brand == "gm"
self.params.put_bool("CarGpsAvailable", car_gps_supported) self.params.put_bool("CarGpsAvailable", car_gps_supported)
if car_gps_supported: if car_gps_supported or self.gm_gps_supported:
self.gps_pm = messaging.PubMaster(['gpsLocationExternal']) self.gps_pm = messaging.PubMaster(['gpsLocationExternal'])
aol_available = always_on_lateral_available(self.CP) aol_available = always_on_lateral_available(self.CP)
@@ -349,39 +372,80 @@ class Car:
FPCS = self.starpilot_card.update(CS, FPCS, self.sm, self.starpilot_toggles) FPCS = self.starpilot_card.update(CS, FPCS, self.sm, self.starpilot_toggles)
return CS, RD, FPCS return CS, RD, FPCS
def _publish_gm_gps(self, now: float) -> None:
if self.sm.updated.get("gpsLocation", False):
self._gm_gps["device"] = (self.sm["gpsLocation"].to_dict(), now) if self.sm.valid["gpsLocation"] else None
for source, sample in self.CI.CS.get_car_gps_sources().items():
previous = self._gm_gps[source]
if sample is None:
self._gm_gps[source] = None
elif previous is None or sample["timestamp_nanos"] > previous[0]["timestamp_nanos"]:
self._gm_gps[source] = (sample.copy(), now)
# Device > PPS > OnStar, with each source's own freshness window.
selected = None
for source, timeout in GM_GPS_SOURCES:
candidate = self._gm_gps[source]
if candidate is not None and now - candidate[1] <= timeout and _gps_sample_is_healthy(candidate[0]):
selected = candidate[0]
break
if selected is None:
if not self._gm_gps_had_fix:
return
elif self._gm_gps_had_fix and (now - self._last_car_gps_publish_monotonic < 0.2):
return
gps_send = messaging.new_message('gpsLocationExternal', valid=selected is not None)
if selected is not None:
gps_send.gpsLocationExternal = {key: value for key, value in selected.items() if key != "timestamp_nanos"}
if source != "device":
gps_send.gpsLocationExternal.source = "car"
else:
# Clear subscribers' cached fix once, then let the service become stale.
gps_send.gpsLocationExternal.hasFix = False
assert self.gps_pm is not None
self.gps_pm.send('gpsLocationExternal', gps_send)
self._gm_gps_had_fix = selected is not None
self._last_car_gps_publish_monotonic = now
def state_publish(self, CS: car.CarState, RD: structs.RadarDataT | None, FPCS: custom.StarPilotCarState): def state_publish(self, CS: car.CarState, RD: structs.RadarDataT | None, FPCS: custom.StarPilotCarState):
"""carState and carParams publish loop""" """carState and carParams publish loop"""
get_car_gps = getattr(self.CI.CS, 'get_car_gps', None)
car_gps = get_car_gps() if get_car_gps is not None else None
now = time.monotonic() now = time.monotonic()
if car_gps is not None and car_gps['timestamp_nanos'] > self._last_car_gps_timestamp_nanos: if self.gm_gps_supported:
self._last_car_gps_timestamp_nanos = car_gps['timestamp_nanos'] self._publish_gm_gps(now)
self._last_car_gps_received_monotonic = now else:
get_car_gps = getattr(self.CI.CS, 'get_car_gps', None)
car_gps = get_car_gps() if get_car_gps is not None else None
if car_gps is not None and car_gps['timestamp_nanos'] > self._last_car_gps_timestamp_nanos:
self._last_car_gps_timestamp_nanos = car_gps['timestamp_nanos']
self._last_car_gps_received_monotonic = now
if car_gps is not None and self._last_car_gps_received_monotonic > 0.0 and \ if car_gps is not None and self._last_car_gps_received_monotonic > 0.0 and \
now - self._last_car_gps_received_monotonic <= 2.5 and \ now - self._last_car_gps_received_monotonic <= 2.5 and \
now - self._last_car_gps_publish_monotonic >= 0.2: now - self._last_car_gps_publish_monotonic >= 0.2:
gps_send = messaging.new_message('gpsLocationExternal', valid=True) gps_send = messaging.new_message('gpsLocationExternal', valid=True)
gps = gps_send.gpsLocationExternal gps = gps_send.gpsLocationExternal
gps.flags = 0 gps.flags = 0
gps.latitude = car_gps['latitude'] gps.latitude = car_gps['latitude']
gps.longitude = car_gps['longitude'] gps.longitude = car_gps['longitude']
gps.altitude = car_gps['altitude'] gps.altitude = car_gps['altitude']
gps.speed = car_gps['speed'] gps.speed = car_gps['speed']
gps.bearingDeg = car_gps['bearingDeg'] gps.bearingDeg = car_gps['bearingDeg']
gps.horizontalAccuracy = car_gps['horizontalAccuracy'] gps.horizontalAccuracy = car_gps['horizontalAccuracy']
gps.unixTimestampMillis = car_gps['unixTimestampMillis'] gps.unixTimestampMillis = car_gps['unixTimestampMillis']
gps.source = log.GpsLocationData.SensorSource.car gps.source = log.GpsLocationData.SensorSource.car
gps.vNED = car_gps['vNED'] gps.vNED = car_gps['vNED']
gps.verticalAccuracy = car_gps['verticalAccuracy'] gps.verticalAccuracy = car_gps['verticalAccuracy']
gps.bearingAccuracyDeg = car_gps['bearingAccuracyDeg'] gps.bearingAccuracyDeg = car_gps['bearingAccuracyDeg']
gps.speedAccuracy = car_gps['speedAccuracy'] gps.speedAccuracy = car_gps['speedAccuracy']
gps.hasFix = car_gps['hasFix'] gps.hasFix = car_gps['hasFix']
gps.satelliteCount = car_gps['satelliteCount'] gps.satelliteCount = car_gps['satelliteCount']
assert self.gps_pm is not None assert self.gps_pm is not None
self.gps_pm.send('gpsLocationExternal', gps_send) self.gps_pm.send('gpsLocationExternal', gps_send)
self._last_car_gps_publish_monotonic = now self._last_car_gps_publish_monotonic = now
# carParams - logged every 50 seconds (> 1 per segment) # carParams - logged every 50 seconds (> 1 per segment)
if self.sm.frame % int(50. / DT_CTRL) == 0: if self.sm.frame % int(50. / DT_CTRL) == 0:
+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._uses_software_cruise() remembered_resume = resume_prev_button and (self.gm_cc_only or self.redneck_non_pcm)
if self.v_cruise_initialized and (resume_pressed or remembered_resume): if self.v_cruise_initialized and (resume_pressed or remembered_resume):
self.v_cruise_kph = self.v_cruise_kph_last self.v_cruise_kph = self.v_cruise_kph_last
+25 -35
View File
@@ -313,22 +313,6 @@ class TestVCruiseHelper:
assert V_CRUISE_MIN <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX assert V_CRUISE_MIN <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX
assert self.v_cruise_helper.v_cruise_initialized assert self.v_cruise_helper.v_cruise_initialized
def test_resume_keeps_previous_software_cruise_speed(self):
engage_cs = car.CarState(vEgo=75 * CV.MPH_TO_MS)
self.v_cruise_helper.initialize_v_cruise(engage_cs, experimental_mode=False, resume_prev_button=False,
starpilot_toggles=self.starpilot_toggles)
disabled_cs = car.CarState(cruiseState={"available": True})
self.v_cruise_helper.update_v_cruise(disabled_cs, enabled=False, is_metric=False,
speed_limit_changed=False, starpilot_toggles=self.starpilot_toggles)
resume_cs = car.CarState(vEgo=22 * CV.MPH_TO_MS)
self.v_cruise_helper.initialize_v_cruise(resume_cs, experimental_mode=False, resume_prev_button=True,
starpilot_toggles=self.starpilot_toggles)
assert self.v_cruise_helper.v_cruise_kph == pytest.approx(75 * CV.MPH_TO_KPH)
assert self.v_cruise_helper.v_cruise_cluster_kph == pytest.approx(75 * CV.MPH_TO_KPH)
def test_initialize_v_cruise_matches_speed_limit(self): def test_initialize_v_cruise_matches_speed_limit(self):
self.reset_cruise_speed_state() self.reset_cruise_speed_state()
self.starpilot_toggles.set_speed_limit = True self.starpilot_toggles.set_speed_limit = True
@@ -499,33 +483,39 @@ 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_always_tracks_pcm_speed(self, openpilot_longitudinal): def test_pcm_cruise_uses_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
samples = ( cs = car.CarState(
(72.0, 71.0, None), cruiseState={
(25.0, 25.0, {"type": ButtonType.decelCruise, "pressed": True}), "available": True,
(65.0, 65.0, {"type": ButtonType.decelCruise, "pressed": False}), "speed": pcm_speed_kph * CV.KPH_TO_MS,
(90.0, 90.0, {"type": ButtonType.accelCruise, "pressed": True}), "speedCluster": pcm_cluster_speed_kph * CV.KPH_TO_MS,
(5.0, 5.0, {"type": ButtonType.accelCruise, "pressed": False}), },
) )
for pcm_speed_kph, pcm_cluster_speed_kph, button_event in samples:
cs = car.CarState( helper.update_v_cruise(cs, True, True, False, toggles)
cruiseState={ assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph)
"available": True, assert helper.v_cruise_cluster_kph == pytest.approx(pcm_cluster_speed_kph)
"speed": pcm_speed_kph * CV.KPH_TO_MS,
"speedCluster": pcm_cluster_speed_kph * CV.KPH_TO_MS, next_pcm_speed_kph = 74.0
}, next_cs = car.CarState(
buttonEvents=[] if button_event is None else [button_event], cruiseState={
) "available": True,
helper.update_v_cruise(cs, True, True, False, toggles) "speed": next_pcm_speed_kph * CV.KPH_TO_MS,
assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph) "speedCluster": next_pcm_speed_kph * CV.KPH_TO_MS,
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:
+169
View File
@@ -0,0 +1,169 @@
from types import SimpleNamespace
import pytest
from cereal import messaging
from openpilot.selfdrive.car.card import Car, GM_GPS_SOURCES
def sample():
gps = messaging.new_message("gpsLocation", valid=True).gpsLocation
gps.hasFix = True
gps.latitude = 38.3
gps.longitude = -85.7
gps.source = "ublox"
return dict(gps.to_dict(), timestamp_nanos=1)
@pytest.fixture
def selector():
card = Car.__new__(Car)
card._gm_gps = dict.fromkeys(source for source, _ in GM_GPS_SOURCES)
card._gm_gps_had_fix = False
card._last_car_gps_publish_monotonic = 0.0
card.gps_pm = SimpleNamespace(sent=[])
card.gps_pm.send = lambda service, message: card.gps_pm.sent.append((service, message))
card.sm = SimpleNamespace(updated={"gpsLocation": False})
card.CI = SimpleNamespace(CS=SimpleNamespace(get_car_gps_sources=dict))
return card
@pytest.mark.parametrize("states,winner", [
(("good", None, None), "device"),
(("bad", "good", None), "pps"),
(("stale", "good", None), "pps"),
(("bad", "bad", "good"), "onstar"),
(("good", "good", "good"), "device"),
(("bad", "stale", None), None),
(("stale", "stale", "good"), "onstar"),
(("bad", "bad", "bad"), None),
(("stale", "stale", "stale"), None),
])
def test_priority(selector, states, winner):
for index, ((source, timeout), state) in enumerate(zip(GM_GPS_SOURCES, states, strict=True)):
if state is not None:
fix = sample() | {"latitude": 38.0 + index, "hasFix": state != "bad"}
selector._gm_gps[source] = (fix, 10.0 - timeout - 0.01 if state == "stale" else 10.0)
selector._publish_gm_gps(10.0)
if winner is None:
assert not selector.gps_pm.sent
else:
assert selector.gps_pm.sent[-1][1].gpsLocationExternal.latitude == selector._gm_gps[winner][0]["latitude"]
@pytest.mark.parametrize("source,timeout", [("device", 2.0), ("pps", 1.0), ("onstar", 2.5)])
def test_freshness_and_recovery(selector, source, timeout):
selector._gm_gps[source] = (sample(), 10.0)
selector._publish_gm_gps(10.0 + timeout)
assert selector.gps_pm.sent[-1][1].valid
selector._publish_gm_gps(10.01 + timeout)
assert not selector.gps_pm.sent[-1][1].valid
selector._gm_gps[source] = (sample(), 20.0)
selector._publish_gm_gps(20.0)
assert selector.gps_pm.sent[-1][1].valid
@pytest.mark.parametrize("source", ["device", "pps"])
def test_upward_recovery(selector, source):
selector._gm_gps["onstar"] = (sample() | {"latitude": 40.0}, 10.0)
selector._publish_gm_gps(10.0)
assert selector.gps_pm.sent[-1][1].gpsLocationExternal.latitude == 40.0
selector._gm_gps[source] = (sample(), 10.21)
selector._publish_gm_gps(10.21)
assert selector.gps_pm.sent[-1][1].gpsLocationExternal.latitude == 38.3
@pytest.mark.parametrize("changes,expected", [
({"latitude": float("nan")}, False), ({"longitude": float("inf")}, False),
({"latitude": 91}, False), ({"longitude": -181}, False), ({"longitude": 181}, False),
({"latitude": 0, "longitude": 0}, False), ({"speed": float("nan")}, False),
({"latitude": 0, "longitude": 10}, True), ({"latitude": 10, "longitude": 0}, True),
({"horizontalAccuracy": 500.0}, True),
])
def test_health(selector, changes, expected):
selector._gm_gps = {source: (sample() | {"latitude": 40.0}, 10.0) for source, _ in GM_GPS_SOURCES}
selector._gm_gps["device"] = (sample() | changes, 10.0)
selector._publish_gm_gps(10.0)
gps = selector.gps_pm.sent[-1][1].gpsLocationExternal
assert gps.latitude == (selector._gm_gps["device"][0]["latitude"] if expected else 40.0)
assert gps.source == ("ublox" if expected else "car")
@pytest.mark.parametrize("source", [source for source, _ in GM_GPS_SOURCES])
def test_selected_fix_fields_are_preserved(selector, source):
fix = sample() | {"altitude": 123.0, "speed": 10.0, "vNED": [0.0, 10.0, 0.0], "bearingDeg": 90.0,
"horizontalAccuracy": 3.0, "verticalAccuracy": 5.0, "bearingAccuracyDeg": 2.0}
if source == "device":
selector._gm_gps[source] = (fix, 10.0)
else:
selector.CI.CS.get_car_gps_sources = lambda: {source: fix}
selector._publish_gm_gps(10.0)
service, message = selector.gps_pm.sent[-1]
assert service == "gpsLocationExternal"
assert message.valid
expected = {key: value for key, value in fix.items() if key != "timestamp_nanos"}
expected["source"] = "ublox" if source == "device" else "car"
assert message.gpsLocationExternal.to_dict() == expected
@pytest.mark.parametrize("source", ["pps", "onstar"])
def test_can_cache_freshness_and_explicit_invalidation(selector, source):
sources = {"pps": sample(), "onstar": sample() | {"latitude": 40.0}}
selector.CI.CS.get_car_gps_sources = lambda: sources
selector._publish_gm_gps(10.0)
for timestamp in (1, 0):
sources[source] = sources[source] | {"timestamp_nanos": timestamp}
selector._publish_gm_gps(10.5)
assert selector._gm_gps[source][1] == 10.0
sources[source] = sources[source] | {"timestamp_nanos": 2}
selector._publish_gm_gps(10.6)
assert selector._gm_gps[source][1] == 10.6
sources[source] = None
selector._publish_gm_gps(10.9)
assert selector._gm_gps[source] is None
assert selector.gps_pm.sent[-1][1].gpsLocationExternal.latitude == (40.0 if source == "pps" else 38.3)
def test_no_source_publication_transition(selector):
selector._publish_gm_gps(10.0)
assert not selector.gps_pm.sent
selector._gm_gps["device"] = (sample(), 10.0)
selector._publish_gm_gps(10.0)
valid = selector.gps_pm.sent[-1][1]
assert valid.valid and valid.gpsLocationExternal.hasFix
assert valid.gpsLocationExternal.latitude == 38.3
selector._publish_gm_gps(10.1)
assert len(selector.gps_pm.sent) == 1
selector._publish_gm_gps(12.1)
invalid = selector.gps_pm.sent[-1][1]
assert not invalid.valid and not invalid.gpsLocationExternal.hasFix
for now in (12.12, 12.13, 12.14):
selector._publish_gm_gps(now)
assert len(selector.gps_pm.sent) == 2
selector._gm_gps["device"] = (sample(), 12.15)
selector._publish_gm_gps(12.15) # Recovery bypasses the 200 ms healthy cadence.
assert len(selector.gps_pm.sent) == 3
assert selector.gps_pm.sent[-1][1].gpsLocationExternal.hasFix
for now in (15.0, 20.0, 30.0):
selector._publish_gm_gps(now)
assert len(selector.gps_pm.sent) == 4 # One more loss marker, then sustained silence.
def test_device_updates_honor_event_validity(selector):
message = messaging.new_message("gpsLocation", valid=True)
message.gpsLocation = {key: value for key, value in sample().items() if key != "timestamp_nanos"}
class DeviceMessages(dict):
updated = {"gpsLocation": True}
valid = {"gpsLocation": True}
selector.sm = DeviceMessages(gpsLocation=message.gpsLocation)
selector._publish_gm_gps(10.0)
assert selector.gps_pm.sent[-1][1].valid
selector.sm.updated["gpsLocation"] = False
selector._publish_gm_gps(10.05)
assert selector._gm_gps["device"][1] == 10.0
selector.sm.updated["gpsLocation"] = True
selector.sm.valid["gpsLocation"] = False
selector._publish_gm_gps(10.1)
assert selector._gm_gps["device"] is None
assert len(selector.gps_pm.sent) == 2 # Loss bypasses the healthy cadence too.
assert not selector.gps_pm.sent[-1][1].gpsLocationExternal.hasFix
@@ -639,9 +639,6 @@ 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,27 +275,20 @@ GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30
GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08 GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.26 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.16
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.75 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.25
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.30 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.25
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20
GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12
GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.22
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10
GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0 GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0
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
@@ -314,7 +307,7 @@ GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT = 0.14
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT_WIDTH = 0.05 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT_WIDTH = 0.05
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED = 6.0 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED = 6.0
GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED_WIDTH = 1.5 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED_WIDTH = 1.5
GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX = 0.08 GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST = 0.00
GENESIS_G70_CURVE_UNWIND_SPEED = 18.0 GENESIS_G70_CURVE_UNWIND_SPEED = 18.0
GENESIS_G70_CURVE_UNWIND_SPEED_WIDTH = 3.0 GENESIS_G70_CURVE_UNWIND_SPEED_WIDTH = 3.0
GENESIS_G70_CURVE_UNWIND_LAT = 0.25 GENESIS_G70_CURVE_UNWIND_LAT = 0.25
@@ -3253,18 +3246,6 @@ 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
@@ -3313,8 +3294,7 @@ def get_genesis_g70_curve_unwind_output_scale(desired_lateral_accel: float, desi
GENESIS_G70_CURVE_UNWIND_LAT_WIDTH) GENESIS_G70_CURVE_UNWIND_LAT_WIDTH)
jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_JERK) / jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_JERK) /
GENESIS_G70_CURVE_UNWIND_JERK_WIDTH) GENESIS_G70_CURVE_UNWIND_JERK_WIDTH)
reduction = (GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX * speed_weight * lateral_weight * jerk_weight) return 1.0 + GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST * speed_weight * lateral_weight * jerk_weight
return 1.0 - reduction
def get_genesis_g70_unwind_ff_scale(setpoint: float, measured_lateral_accel: float, def get_genesis_g70_unwind_ff_scale(setpoint: float, measured_lateral_accel: float,
@@ -56,10 +56,6 @@ HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_EGO_SPEED = 2.0
HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_SPEED = 0.5 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_SPEED = 0.5
HYUNDAI_ELANTRA_STOPPED_LEAD_MIN_CLOSING_SPEED = 0.25 HYUNDAI_ELANTRA_STOPPED_LEAD_MIN_CLOSING_SPEED = 0.25
HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_CREEP_ACCEL = 0.05 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_CREEP_ACCEL = 0.05
HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED = 1.0
HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED]
HYUNDAI_ELANTRA_FINAL_STOP_CAP_V = [-0.20, -0.25, -0.35, -0.55]
HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN = 0.45
HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0 HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED]
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90]
@@ -173,21 +169,7 @@ class LongControlVehicleTuning:
self.subaru_stop_release_frames = 0 self.subaru_stop_release_frames = 0
def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel): def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel):
"""Shape low-speed stop braking without overriding urgent targets.""" """Release a stale hard lead brake once the stop target has eased."""
if (
self.is_hyundai_elantra_2021 and
should_stop and
v_ego < HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED and
a_target <= 0.1
):
final_stop_cap = float(interp(
v_ego,
HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP,
HYUNDAI_ELANTRA_FINAL_STOP_CAP_V,
))
if a_target > final_stop_cap - HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN:
return max(float(output_accel), final_stop_cap)
if ( if (
self.is_hyundai_santa_fe_2022 and self.is_hyundai_santa_fe_2022 and
v_ego <= HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED and v_ego <= HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED and
@@ -397,27 +397,6 @@ def get_vehicle_min_accel(CP, v_ego):
return float(ACCEL_MIN) return float(ACCEL_MIN)
def get_far_lead_coast_cap(lead, v_ego, desired_gap, output_a_target):
if lead is None or not bool(getattr(lead, "status", False)):
return float(output_a_target)
v_ego = float(v_ego)
lead_distance = float(getattr(lead, "dRel", float("inf")))
lead_speed = float(getattr(lead, "vLead", v_ego))
closing_speed = v_ego - lead_speed
if (
v_ego <= 10.0 or
closing_speed <= 0.5 or
lead_distance < FAR_LEAD_COAST_MIN_DISTANCE or
lead_distance <= float(desired_gap) + FAR_LEAD_COAST_MIN_GAP_MARGIN or
lead_distance / max(closing_speed, 0.1) < FAR_LEAD_COAST_MIN_TTC or
max(0.0, -float(getattr(lead, "aLeadK", 0.0))) > FAR_LEAD_COAST_MAX_LEAD_BRAKE
):
return float(output_a_target)
return max(float(output_a_target), -FAR_LEAD_COAST_MAX_DECEL)
# Restored planner constants retained by CEM, stop, and departure paths. # Restored planner constants retained by CEM, stop, and departure paths.
A_CRUISE_MIN = -1.0 A_CRUISE_MIN = -1.0
# The stop distance runs ~9 m long through the mid-approach, which leaves the obstacle slack # The stop distance runs ~9 m long through the mid-approach, which leaves the obstacle slack
@@ -454,11 +433,6 @@ VEHICLE_FAR_FOLLOW_SLEW_MIN_DISTANCE_TIME = 1.35
VEHICLE_FAR_FOLLOW_SLEW_MIN_HEADWAY = 1.35 VEHICLE_FAR_FOLLOW_SLEW_MIN_HEADWAY = 1.35
VEHICLE_FAR_FOLLOW_SLEW_MIN_TTC = 8.0 VEHICLE_FAR_FOLLOW_SLEW_MIN_TTC = 8.0
VEHICLE_FAR_FOLLOW_SLEW_MAX_LATERAL_OFFSET = 1.5 VEHICLE_FAR_FOLLOW_SLEW_MAX_LATERAL_OFFSET = 1.5
FAR_LEAD_COAST_MIN_DISTANCE = 45.0
FAR_LEAD_COAST_MIN_TTC = 8.0
FAR_LEAD_COAST_MIN_GAP_MARGIN = 6.0
FAR_LEAD_COAST_MAX_LEAD_BRAKE = 0.35
FAR_LEAD_COAST_MAX_DECEL = 0.20
RADAR_DEPART_CONFLICT_MAX_EGO_SPEED = 1.6 RADAR_DEPART_CONFLICT_MAX_EGO_SPEED = 1.6
RADAR_DEPART_CONFLICT_MIN_RADAR_LATERAL = 1.5 RADAR_DEPART_CONFLICT_MIN_RADAR_LATERAL = 1.5
RADAR_DEPART_CONFLICT_MAX_RADAR_DISTANCE = 18.0 RADAR_DEPART_CONFLICT_MAX_RADAR_DISTANCE = 18.0
@@ -3095,28 +3069,6 @@ class LongitudinalPlanner:
panic_bypass, panic_bypass,
) )
far_lead_coast_allowed = (
not experimental_mode and
comfort_lead is not None and
desired_gap is not None and
not output_should_stop and
not vision_low_speed_stop_active and
not close_lead_caps and
not panic_bypass and
not depart_safety_veto and
inside_gap_closing_cap is None and
not bool(getattr(sm['starpilotPlan'], 'forcingStop', False)) and
not bool(getattr(sm['starpilotPlan'], 'redLight', False)) and
not bool(getattr(sm['starpilotPlan'], 'stopSignConfirmed', False))
)
if far_lead_coast_allowed:
output_a_target = get_far_lead_coast_cap(
comfort_lead,
scene_v_ego,
desired_gap,
output_a_target,
)
if radar_gap_settle_active: if radar_gap_settle_active:
output_a_target = RADAR_STANDSTILL_GAP_SETTLE_ACCEL output_a_target = RADAR_STANDSTILL_GAP_SETTLE_ACCEL
output_should_stop = False output_should_stop = False
+1 -8
View File
@@ -7,7 +7,6 @@ from typing import Any
import capnp import capnp
from cereal import messaging, log, car, custom from cereal import messaging, log, car, custom
from cereal.services import SERVICE_LIST
from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process
@@ -43,11 +42,6 @@ def is_bosch_a_radar_car(CP) -> bool:
return CP.brand == "honda" and CP.carFingerprint in HONDA_BOSCH_A and not CP.radarUnavailable return CP.brand == "honda" and CP.carFingerprint in HONDA_BOSCH_A and not CP.radarUnavailable
def has_slow_radar_tracks(CP) -> bool:
radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL)
return not CP.radarUnavailable and radar_ts > 2.0 / SERVICE_LIST["liveTracks"].frequency
# Adjacent-lane stopped-vehicle detector, used as a stop-line hint on red-light # Adjacent-lane stopped-vehicle detector, used as a stop-line hint on red-light
# approaches. The qualifier is the DECELERATION HISTORY, not the current speed: roadside # approaches. The qualifier is the DECELERATION HISTORY, not the current speed: roadside
# furniture and curb-parked cars never show a moving -> stopped transition, so testing # furniture and curb-parked cars never show a moving -> stopped transition, so testing
@@ -642,9 +636,8 @@ def main() -> None:
cloudlog.info("radard got CarParams") cloudlog.info("radard got CarParams")
# *** setup messaging # *** setup messaging
ignore_avg_freq = ['liveTracks'] if has_slow_radar_tracks(CP) else None
sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2', sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2',
ignore_avg_freq=ignore_avg_freq, ignore_valid=['starpilotPlan']) ignore_valid=['starpilotPlan'])
pm = messaging.PubMaster(['radarState']) pm = messaging.PubMaster(['radarState'])
radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL) radar_ts = float(getattr(CP, "radarTimeStepDEPRECATED", DT_MDL) or DT_MDL)
+1 -8
View File
@@ -54,7 +54,6 @@ 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,
) )
@@ -961,13 +960,7 @@ class TestLatControl:
assert get_genesis_g70_low_speed_output_limit(0.0, 2.0) < 0.30 assert get_genesis_g70_low_speed_output_limit(0.0, 2.0) < 0.30
assert get_genesis_g70_low_speed_angle_damping(0.0, -20.0, 0.0, 2.0) < 0.0 assert get_genesis_g70_low_speed_angle_damping(0.0, -20.0, 0.0, 2.0) < 0.0
assert get_genesis_g70_low_speed_angle_damping(0.0, 20.0, 0.0, 2.0) > 0.0 assert get_genesis_g70_low_speed_angle_damping(0.0, 20.0, 0.0, 2.0) > 0.0
assert get_genesis_g70_high_speed_transition_scale(0.0, 0.8, 65.0 * 0.44704) < \ assert get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) == pytest.approx(1.0)
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,7 +14,6 @@ from openpilot.selfdrive.controls.radard import (
RadarD, RadarD,
g90_low_speed_radar_lead_sane, g90_low_speed_radar_lead_sane,
g90_radar_lead_lateral_sane, g90_radar_lead_lateral_sane,
has_slow_radar_tracks,
is_bosch_a_radar_car, is_bosch_a_radar_car,
match_vision_to_track, match_vision_to_track,
) )
@@ -97,15 +96,6 @@ class TestLeads:
assert bosch_a.lead_prob_filters[0].dt == pytest.approx(DT_MDL) assert bosch_a.lead_prob_filters[0].dt == pytest.approx(DT_MDL)
assert bosch_a.kalman_params.A[0][1] == pytest.approx(HONDA_BOSCH_A_RADAR_TS) assert bosch_a.kalman_params.A[0][1] == pytest.approx(HONDA_BOSCH_A_RADAR_TS)
def test_slow_radar_frequency_relaxation_is_scoped(self):
slow_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.15, radarUnavailable=False)
normal_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.1, radarUnavailable=False)
unavailable_radar = SimpleNamespace(radarTimeStepDEPRECATED=0.15, radarUnavailable=True)
assert has_slow_radar_tracks(slow_radar)
assert not has_slow_radar_tracks(normal_radar)
assert not has_slow_radar_tracks(unavailable_radar)
@pytest.mark.skipif(platform.system() == "Darwin", reason="SocketEventHandle requires eventfd") @pytest.mark.skipif(platform.system() == "Darwin", reason="SocketEventHandle requires eventfd")
def test_radar_fault(self): def test_radar_fault(self):
# if there's no radar-related can traffic, radard should either not respond or respond with an error # if there's no radar-related can traffic, radard should either not respond or respond with an error
@@ -765,15 +765,6 @@ def test_elantra_lead_stop_releases_stale_hard_brake_after_target_eases():
assert tuning.shape_stopping_accel(-1.20, -0.25, True, 1.0, False, -0.85) == pytest.approx(-1.20) assert tuning.shape_stopping_accel(-1.20, -0.25, True, 1.0, False, -0.85) == pytest.approx(-1.20)
def test_elantra_final_stop_cap_softens_normal_low_speed_stop():
CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")
tuning = vehicle_tunes.LongControlVehicleTuning(CP)
assert tuning.shape_stopping_accel(-0.85, -0.25, True, 0.5, False, -0.85) == pytest.approx(-0.35)
assert tuning.shape_stopping_accel(-0.85, -1.25, True, 0.5, False, -0.85) == pytest.approx(-0.85)
assert tuning.shape_stopping_accel(-0.85, -0.25, False, 0.5, False, -0.85) == pytest.approx(-0.85)
def test_elantra_stopped_lead_handoff_holds_braking_direction_without_touching_brakes(): def test_elantra_stopped_lead_handoff_holds_braking_direction_without_touching_brakes():
CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021") CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")
tuning = vehicle_tunes.LongControlVehicleTuning(CP) tuning = vehicle_tunes.LongControlVehicleTuning(CP)
@@ -18,13 +18,7 @@ from opendbc.car.toyota.values import CAR as TOYOTA_CAR
import openpilot.selfdrive.controls.lib.longitudinal_planner as longitudinal_planner_module import openpilot.selfdrive.controls.lib.longitudinal_planner as longitudinal_planner_module
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.selfdrive.controls.lib.longitudinal_planner import ( from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, get_coast_accel, get_vehicle_min_accel, should_publish_planner_fcw
LongitudinalPlanner,
get_coast_accel,
get_far_lead_coast_cap,
get_vehicle_min_accel,
should_publish_planner_fcw,
)
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import ( from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import (
LongitudinalMpc, LongitudinalMpc,
build_model_lead_trajectory, build_model_lead_trajectory,
@@ -316,24 +310,6 @@ def test_mpc_panic_bypass_immediately_removes_duplicate_vision_filter():
assert mpc.lead_v_filter.x == pytest.approx(10.0) assert mpc.lead_v_filter.x == pytest.approx(10.0)
def test_far_lead_coast_cap_delays_nonurgent_deceleration():
lead = make_lead(status=True, d_rel=128.0, v_lead=16.7, a_lead=0.2, radar=True)
assert get_far_lead_coast_cap(lead, 26.6, 115.0, -0.43) == pytest.approx(-0.20)
assert get_far_lead_coast_cap(lead, 26.6, 115.0, 0.10) == pytest.approx(0.10)
@pytest.mark.parametrize("d_rel,v_lead,a_lead,desired_gap", [
(50.0, 20.0, 0.2, 45.0), # only a small gap remains
(128.0, 8.0, 0.2, 115.0), # urgent closing time
(128.0, 16.7, -0.5, 115.0), # the lead is braking materially
])
def test_far_lead_coast_cap_preserves_urgent_or_close_deceleration(d_rel, v_lead, a_lead, desired_gap):
lead = make_lead(status=True, d_rel=d_rel, v_lead=v_lead, a_lead=a_lead, radar=True)
assert get_far_lead_coast_cap(lead, 26.6, desired_gap, -0.43) == pytest.approx(-0.43)
def test_hrv_far_follow_output_slew_damps_only_continuous_safe_follow(): def test_hrv_far_follow_output_slew_damps_only_continuous_safe_follow():
v_ego = 24.0 v_ego = 24.0
CP = CarInterface.get_non_essential_params(CAR.HONDA_HRV_3G) CP = CarInterface.get_non_essential_params(CAR.HONDA_HRV_3G)
@@ -37,7 +37,7 @@ def make_toggles(**overrides):
def test_force_stop_jerk_scale_is_platform_specific(): def test_force_stop_jerk_scale_is_platform_specific():
assert get_force_stop_jerk_scale(SimpleNamespace(carFingerprint="HYUNDAI_ELANTRA_2021")) == 0.80 assert get_force_stop_jerk_scale(SimpleNamespace(carFingerprint="HYUNDAI_ELANTRA_2021")) == 0.80
assert get_force_stop_jerk_scale(SimpleNamespace(carFingerprint="OTHER_CAR")) == 0.32 assert get_force_stop_jerk_scale(SimpleNamespace(carFingerprint="OTHER_CAR")) == 0.20
def test_lead_follow_jerk_scale_is_platform_specific(): def test_lead_follow_jerk_scale_is_platform_specific():
@@ -255,3 +255,18 @@ def test_untracked_vision_lead_still_uses_strict_entry_gate():
assert not planner.update_lead_status(16.8) assert not planner.update_lead_status(16.8)
finally: finally:
planner.shutdown() planner.shutdown()
def test_gps_location_service_updates_on_carparams(monkeypatch):
planner = make_planner(monkeypatch)
try:
sm = make_sm(planner, frame=1, v_ego=20.0, left_blinker=False)
sm.updated = {"carParams": True}
sm["carParams"] = SimpleNamespace(brand="gm")
sm["gpsLocationExternal"] = SimpleNamespace(latitude=2.0, longitude=2.0, bearingDeg=45.0, hasFix=True)
planner.update(0.0, False, sm, make_toggles())
assert planner.gps_location_service == "gpsLocationExternal"
assert planner.gps_position["latitude"] == 2.0
finally:
planner.shutdown()
+21 -1
View File
@@ -32,7 +32,6 @@ 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(
@@ -60,6 +59,13 @@ 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"]),
@@ -81,6 +87,7 @@ 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)
@@ -107,7 +114,13 @@ 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
@@ -116,6 +129,7 @@ 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),
): ):
@@ -135,6 +149,12 @@ 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") and not starpilot_state.car_state.isToyota), visible=lambda: self._params.get_bool("QOLLongitudinal")),
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,12 +739,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("CustomCruiseLong"))), current_value=max(1, self._params.get_float("CustomCruiseLong"))),
visible=lambda: self._params.get_bool("QOLLongitudinal") and not starpilot_state.car_state.isToyota), visible=lambda: self._params.get_bool("QOLLongitudinal")),
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,7 +12,6 @@ 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:
@@ -46,6 +45,7 @@ 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,6 +53,9 @@ 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)
@@ -66,6 +69,7 @@ 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,
]) ])
@@ -76,6 +80,7 @@ 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),
) )
@@ -84,7 +89,7 @@ class DeveloperLayoutMici(NavScroller):
self._disable_wide_road_toggle, self._disable_wide_road_toggle,
self._joystick_toggle, self._joystick_toggle,
) )
engaged_blocked_toggles = (self._alpha_long_toggle,) engaged_blocked_toggles = (self._long_maneuver_toggle, 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:
@@ -124,7 +129,13 @@ 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
@@ -134,8 +145,16 @@ 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,7 +63,6 @@ class VisualsLayoutMici(NavScroller):
self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget") self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget")
self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath") self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath")
self._lead_indicator_btn = LeadIndicatorBigButton() self._lead_indicator_btn = LeadIndicatorBigButton()
self._lead_info_btn = BigParamControl("show lead speed", "LeadInfo")
self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits") self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits")
self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation") self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation")
self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower") self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower")
@@ -77,7 +76,6 @@ class VisualsLayoutMici(NavScroller):
self._torque_bar_btn, self._torque_bar_btn,
self._rainbow_path_btn, self._rainbow_path_btn,
self._lead_indicator_btn, self._lead_indicator_btn,
self._lead_info_btn,
self._speed_limit_signs_btn, self._speed_limit_signs_btn,
self._slc_confirmation_btn, self._slc_confirmation_btn,
self._slc_confirmation_lower_btn, self._slc_confirmation_lower_btn,
@@ -95,7 +93,6 @@ class VisualsLayoutMici(NavScroller):
def _refresh(self): def _refresh(self):
self._camera_view_btn.refresh() self._camera_view_btn.refresh()
self._lead_indicator_btn.refresh() self._lead_indicator_btn.refresh()
self._lead_info_btn.set_enabled(lead_indicator_enabled(self._lead_info_btn.params, hide_by_default=True))
confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation") confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation")
self._slc_confirmation_lower_btn.set_visible(confirmation_enabled) self._slc_confirmation_lower_btn.set_visible(confirmation_enabled)
self._slc_confirmation_higher_btn.set_visible(confirmation_enabled) self._slc_confirmation_higher_btn.set_visible(confirmation_enabled)
+3 -35
View File
@@ -13,9 +13,8 @@ from openpilot.selfdrive.ui.onroad.starpilot.rainbow_path import RainbowPath
from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets import Widget
CLIP_MARGIN = 500 CLIP_MARGIN = 500
@@ -67,7 +66,6 @@ class ModelRenderer(Widget):
self._lane_line_probs = np.zeros(4, dtype=np.float32) self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()] self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._lead_info_enabled = False
self._path_offset_z = HEIGHT_INIT[0] self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects # Initialize ModelPoints objects
@@ -138,7 +136,6 @@ class ModelRenderer(Widget):
model = sm['modelV2'] model = sm['modelV2']
radar_state = sm['radarState'] if sm.valid['radarState'] else None radar_state = sm['radarState'] if sm.valid['radarState'] else None
lead_one = radar_state.leadOne if radar_state else None lead_one = radar_state.leadOne if radar_state else None
self._lead_info_enabled = self._params.get_bool("LeadInfo")
render_lead_indicator = self._should_render_lead_indicator(radar_state) render_lead_indicator = self._should_render_lead_indicator(radar_state)
# Update model data when needed # Update model data when needed
@@ -162,7 +159,7 @@ class ModelRenderer(Widget):
self._draw_path(sm) self._draw_path(sm)
if render_lead_indicator and radar_state: if render_lead_indicator and radar_state:
self._draw_lead_indicator(radar_state) self._draw_lead_indicator()
def _should_render_lead_indicator(self, radar_state) -> bool: def _should_render_lead_indicator(self, radar_state) -> bool:
return radar_state is not None and lead_indicator_enabled(self._params, hide_by_default=True) return radar_state is not None and lead_indicator_enabled(self._params, hide_by_default=True)
@@ -501,7 +498,7 @@ class ModelRenderer(Widget):
] ]
draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient) draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient)
def _draw_lead_indicator(self, radar_state): def _draw_lead_indicator(self):
# Draw lead vehicles if available # Draw lead vehicles if available
lead_color = get_theme_color("LeadMarker", rl.Color(201, 34, 49, 255)) lead_color = get_theme_color("LeadMarker", rl.Color(201, 34, 49, 255))
for lead in self._lead_vehicles: for lead in self._lead_vehicles:
@@ -511,35 +508,6 @@ class ModelRenderer(Widget):
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha)) rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha))
lead_one = radar_state.leadOne
if self._lead_info_enabled and lead_one and lead_one.status:
self._draw_lead_speed(lead_one)
@staticmethod
def _format_lead_speed(lead_speed: float, is_metric: bool, use_si_metrics: bool) -> str:
lead_speed = max(float(lead_speed), 0.0)
if use_si_metrics:
return f"{round(lead_speed)} m/s"
if is_metric:
return f"{round(lead_speed * CV.MS_TO_KPH)} km/h"
return f"{round(lead_speed * CV.MS_TO_MPH)} mph"
def _draw_lead_speed(self, lead_data) -> None:
from openpilot.selfdrive.ui.onroad.starpilot.path import _draw_text_with_outline
text = self._format_lead_speed(
getattr(lead_data, "vLead", 0.0),
ui_state.is_metric,
ui_state.starpilot_toggles.get("UseSiMetrics", False),
)
font = gui_app.font(FontWeight.SEMI_BOLD)
font_size = 40
text_size = measure_text_cached(font, text, font_size)
center_x = self._rect.x + self._rect.width / 2
x = center_x - text_size.x / 2
y = self._rect.y + 22
_draw_text_with_outline(text, float(x), float(y), font, font_size)
@staticmethod @staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int:
"""Get the index corresponding to the given path height""" """Get the index corresponding to the given path height"""
+3 -44
View File
@@ -1,25 +1,19 @@
from types import SimpleNamespace from types import SimpleNamespace
import pytest
import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer
class _FakeParams: class _FakeParams:
def __init__(self, enabled: bool, lead_info: bool = False): def __init__(self, enabled: bool):
self.enabled = enabled self.enabled = enabled
self.lead_info = lead_info
def get(self, key): def get(self, key):
assert key == "HideLeadMarker" assert key == "HideLeadMarker"
return b"0" if self.enabled else b"1" return b"0" if self.enabled else b"1"
def get_bool(self, key): def get_bool(self, key):
if key == "HideLeadMarker": assert key == "HideLeadMarker"
return not self.enabled return not self.enabled
if key == "LeadInfo":
return self.lead_info
raise AssertionError(key)
def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch): def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch):
@@ -37,38 +31,3 @@ def test_lead_indicator_still_honors_disabled_setting():
assert not renderer._should_render_lead_indicator(SimpleNamespace()) assert not renderer._should_render_lead_indicator(SimpleNamespace())
assert not renderer._should_render_lead_indicator(None) assert not renderer._should_render_lead_indicator(None)
@pytest.mark.parametrize(
("is_metric", "use_si_metrics", "expected"),
[
(False, False, "22 mph"),
(True, False, "36 km/h"),
(False, True, "10 m/s"),
],
)
def test_lead_speed_uses_c3_units(is_metric, use_si_metrics, expected):
assert model_renderer.ModelRenderer._format_lead_speed(10.0, is_metric, use_si_metrics) == expected
def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch):
drawn_metrics = []
monkeypatch.setattr(model_renderer, "get_theme_color", lambda *_args: model_renderer.rl.RED)
monkeypatch.setattr(model_renderer.rl, "draw_triangle_fan", lambda *_args: None)
renderer = object.__new__(model_renderer.ModelRenderer)
renderer._lead_info_enabled = True
renderer._lead_vehicles = [
model_renderer.LeadVehicle(
glow=[(1.0, 2.0)] * 3,
chevron=[(1.0, 2.0)] * 3,
fill_alpha=255,
),
model_renderer.LeadVehicle(),
]
renderer._draw_lead_speed = drawn_metrics.append
lead_one = SimpleNamespace(status=True, vLead=10.0)
renderer._draw_lead_indicator(SimpleNamespace(leadOne=lead_one, leadTwo=SimpleNamespace(status=False)))
assert drawn_metrics == [lead_one]
+6 -1
View File
@@ -1022,6 +1022,11 @@ class ModelManager:
model_key = self._canonical_model_key(model_key) model_key = self._canonical_model_key(model_key)
accelerator = str(accelerator or "").strip().lower() accelerator = str(accelerator or "").strip().lower()
try: try:
if accelerator == MODEL_LAB_ACCELERATOR and not external_gpu_available():
handle_error(None, "External GPU required...", "Chestnut is not connected and firmware-ready.",
MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return False
artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator) artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator)
if not model_accelerator_artifact_available(model_key, accelerator): if not model_accelerator_artifact_available(model_key, accelerator):
handle_error(None, "Accelerator artifact unavailable...", handle_error(None, "Accelerator artifact unavailable...",
@@ -1054,7 +1059,7 @@ class ModelManager:
MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return False return False
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!") self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Chestnut artifact downloaded!")
return True return True
finally: finally:
self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
@@ -326,8 +326,7 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili
}, },
}]) }])
(tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata))
# These are precompiled files, so downloading must not require a connected eGPU. monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True)
monkeypatch.setattr(model_manager, "external_gpu_available", lambda: False)
monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"]) monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"])
monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {}) monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {})
calls = [] calls = []
@@ -347,7 +346,7 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili
) )
assert calls[0][3]["execution_device"] == "AMD" assert calls[0][3]["execution_device"] == "AMD"
assert calls[0][5] == ["https://models.example"] assert calls[0][5] == ["https://models.example"]
assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "eGPU variant downloaded!" assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "Chestnut artifact downloaded!"
assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values
@@ -201,8 +201,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "LaneChanges", "parent_key": "LaneChanges",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -341,8 +339,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLateral", "parent_key": "QOLLateral",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1020,8 +1016,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1034,8 +1028,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1121,8 +1113,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalExperimental", "parent_key": "ConditionalExperimental",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1700,14 +1690,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 1.0, "min": 1.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"excluded_vehicle_makes": [
"Lexus",
"Toyota"
],
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1719,28 +1701,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 1.0, "min": 1.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"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"
}, },
@@ -1814,10 +1774,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "simple" "settings_tier": "simple"
}, },
@@ -1831,8 +1787,6 @@
"max": 30.0, "max": 30.0,
"step": 0.5, "step": 0.5,
"precision": 1, "precision": 1,
"unit_type": "vehicle_speed",
"imperial_max": 15.0,
"parent_key": "QOLLongitudinal", "parent_key": "QOLLongitudinal",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2269,12 +2223,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 0,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2286,12 +2234,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 1,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2303,12 +2245,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 2,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2320,12 +2256,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 3,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2337,12 +2267,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 4,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2354,12 +2278,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 5,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2371,12 +2289,6 @@
"ui_type": "numeric", "ui_type": "numeric",
"min": -99.0, "min": -99.0,
"max": 99.0, "max": 99.0,
"step": 1.0,
"precision": 0,
"unit_type": "vehicle_speed",
"metric_min": -150.0,
"metric_max": 150.0,
"unit_range_index": 6,
"parent_key": "SpeedLimitController", "parent_key": "SpeedLimitController",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2429,8 +2341,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2443,8 +2353,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 150.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2477,8 +2385,6 @@
"min": 0.0, "min": 0.0,
"max": 15.0, "max": 15.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"metric_max": 30.0,
"parent_key": "ConditionalChill", "parent_key": "ConditionalChill",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -2548,7 +2454,6 @@
"min": 5, "min": 5,
"max": 80, "max": 80,
"step": 5, "step": 5,
"unit_type": "vehicle_speed",
"parent_key": "VisionSpeedLimitLowLimitFilter", "parent_key": "VisionSpeedLimitLowLimitFilter",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
@@ -4851,12 +4756,12 @@
}, },
{ {
"key": "GalaxyMobileDefault", "key": "GalaxyMobileDefault",
"label": "Use Galaxy (new) by Default", "label": "Try the Big Dipper Web UI",
"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.", "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.",
"picker_description": "Serve Galaxy (new) as the default landing page.", "picker_description": "Serve the Big Dipper as the default landing page.",
"data_type": "bool", "data_type": "bool",
"ui_type": "toggle", "ui_type": "toggle",
"settings_tier": "simple" "settings_tier": "advanced"
}, },
{ {
"key": "AlphaLongitudinalEnabled", "key": "AlphaLongitudinalEnabled",
@@ -4913,7 +4818,6 @@
"min": 0.0, "min": 0.0,
"max": 99.0, "max": 99.0,
"step": 1.0, "step": 1.0,
"unit_type": "vehicle_speed",
"parent_key": "GalaxyDeveloperMode", "parent_key": "GalaxyDeveloperMode",
"settings_tier": "advanced" "settings_tier": "advanced"
}, },
+2 -19
View File
@@ -390,16 +390,6 @@ 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"):
@@ -1355,17 +1345,10 @@ 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 = self.get_value("QOLLongitudinal") quality_of_life_longitudinal = toggle.openpilot_longitudinal and self.get_value("QOLLongitudinal")
quality_of_life_longitudinal = toggle.openpilot_longitudinal and quality_of_life quality_of_life_cruise = self.get_value("QOLLongitudinal") and (toggle.openpilot_longitudinal or not FPCP.pcmCruiseSpeed)
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,14 +328,4 @@ 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
+4
View File
@@ -126,6 +126,10 @@ class StarPilotPlanner:
v_cruise_kph += starpilot_toggles.set_speed_offset v_cruise_kph += starpilot_toggles.set_speed_offset
v_cruise = v_cruise_kph * CV.KPH_TO_MS v_cruise = v_cruise_kph * CV.KPH_TO_MS
v_ego = max(sm["carState"].vEgo, 0) v_ego = max(sm["carState"].vEgo, 0)
updated = getattr(sm, "updated", None)
if updated is not None and updated.get("carParams", False):
cp = sm["carParams"] if "carParams" in sm else None
self.gps_location_service = get_gps_location_service(self.params, cp)
gps_location = sm[self.gps_location_service] gps_location = sm[self.gps_location_service]
self.gps_position = { self.gps_position = {
+3
View File
@@ -310,6 +310,9 @@ def starpilot_thread():
while True: while True:
sm.update() sm.update()
if sm.updated["carParams"]:
gps_location_service = get_gps_location_service(params, sm["carParams"])
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
monotonic_now = time.monotonic() monotonic_now = time.monotonic()
+2 -2
View File
@@ -329,9 +329,9 @@ class BlueZClient:
self.set_device_property(address, "Trusted", "b", True) self.set_device_property(address, "Trusted", "b", True)
self.agent.clear() self.agent.clear()
def connect(self, address: str, timeout: float = 30.0) -> None: def connect(self, address: str) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
self._call(device["path"], DEVICE_IFACE, "Connect", timeout=timeout) self._call(device["path"], DEVICE_IFACE, "Connect", timeout=30.0)
def disconnect(self, address: str) -> None: def disconnect(self, address: str) -> None:
device = self.device_for_address(address) device = self.device_for_address(address)
+31 -115
View File
@@ -18,10 +18,8 @@ SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0 AUDIO_TEST_HOLD_TIME = 3.0
RECONNECT_INTERVAL_SECONDS = 15.0 RECONNECT_INTERVAL_SECONDS = 15.0
CONTROLLER_RECONNECT_INTERVAL_SECONDS = 5.0
RECONNECT_MAX_BACKOFF_SECONDS = 300.0 RECONNECT_MAX_BACKOFF_SECONDS = 300.0
MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0 MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0
CONTROLLER_OFFROAD_DISCONNECT_DELAY_SECONDS = 120.0
class BluetoothController: class BluetoothController:
@@ -38,9 +36,6 @@ class BluetoothController:
self._last_reconnect = 0.0 self._last_reconnect = 0.0
self._reconnect_backoff: dict[str, tuple[int, float]] = {} self._reconnect_backoff: dict[str, tuple[int, float]] = {}
self._manual_disconnect_until: dict[str, float] = {} self._manual_disconnect_until: dict[str, float] = {}
self._offroad_since: float | None = None
self._policy_disconnected: set[str] = set()
self._policy_disconnect_retry_after: dict[str, float] = {}
self._scan_deadline = 0.0 self._scan_deadline = 0.0
self._audio_test_deadline = 0.0 self._audio_test_deadline = 0.0
self._sleep = sleep self._sleep = sleep
@@ -228,8 +223,6 @@ class BluetoothController:
# report NotConnected, and it must not immediately be auto-reconnected. # report NotConnected, and it must not immediately be auto-reconnected.
self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS
self._reconnect_backoff.pop(normalized_address, None) self._reconnect_backoff.pop(normalized_address, None)
self._policy_disconnected.discard(normalized_address)
self._policy_disconnect_retry_after.pop(normalized_address, None)
try: try:
with self._lock: with self._lock:
self._client().disconnect(normalized_address) self._client().disconnect(normalized_address)
@@ -241,8 +234,6 @@ class BluetoothController:
self._client().remove(address) self._client().remove(address)
self._reconnect_backoff.pop(address.upper(), None) self._reconnect_backoff.pop(address.upper(), None)
self._manual_disconnect_until.pop(address.upper(), None) self._manual_disconnect_until.pop(address.upper(), None)
self._policy_disconnected.discard(address.upper())
self._policy_disconnect_retry_after.pop(address.upper(), None)
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper(): if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
self.params.remove("BluetoothAudioAddress") self.params.remove("BluetoothAudioAddress")
elif command == "select_audio": elif command == "select_audio":
@@ -281,122 +272,47 @@ class BluetoothController:
self._client().stop_discovery() self._client().stop_discovery()
self._scan_deadline = 0.0 self._scan_deadline = 0.0
def _maintain_controller_offroad_policy(self, status: dict[str, Any], now: float) -> bool:
if not status["offroad"]:
self._offroad_since = None
if self._policy_disconnected:
for address in self._policy_disconnected:
self._reconnect_backoff.pop(address, None)
self._policy_disconnect_retry_after.clear()
self._last_reconnect = 0.0
return False
if self._offroad_since is None:
self._offroad_since = now
if not self.params.get_bool("BluetoothDisconnectControllersOffroad"):
if self._policy_disconnected:
self._policy_disconnect_retry_after.clear()
self._last_reconnect = 0.0
return False
if now - self._offroad_since < CONTROLLER_OFFROAD_DISCONNECT_DELAY_SECONDS:
return False
for device in status["devices"]:
if not device.get("paired") or not device.get("controller") or not device.get("connected"):
continue
address = str(device["address"]).upper()
if now < self._policy_disconnect_retry_after.get(address, 0.0):
continue
self._policy_disconnected.add(address)
self._policy_disconnect_retry_after[address] = now + RECONNECT_INTERVAL_SECONDS
try:
with self._lock:
self._client().disconnect(address)
except RuntimeError as error:
if "notconnected" not in str(error).replace(" ", "").lower():
self._policy_disconnected.discard(address)
self._policy_disconnect_retry_after.pop(address, None)
cloudlog.warning(f"Bluetooth offroad controller disconnect failed for {address}: {error}")
except Exception as error:
self._policy_disconnected.discard(address)
self._policy_disconnect_retry_after.pop(address, None)
cloudlog.warning(f"Bluetooth offroad controller disconnect failed for {address}: {error}")
return True
def _maintain_reconnects(self, status: dict[str, Any], now: float, suspend_controller_reconnect: bool) -> None:
devices = status["devices"]
devices_by_address = {device["address"].upper(): device for device in devices}
for address in list(self._policy_disconnected):
device = devices_by_address.get(address)
if device is None or not device["paired"] or not device["trusted"]:
self._policy_disconnected.discard(address)
self._reconnect_backoff.pop(address, None)
elif device["connected"]:
self._policy_disconnected.discard(address)
self._reconnect_backoff.pop(address, None)
if self._pairing_address:
return
selected = str(status["selected_audio"])
candidates = [device for device in devices if device["paired"] and device["trusted"] and not device["connected"]]
candidates.sort(key=lambda device: device["address"].upper() != selected.upper())
controller_candidates = {
device["address"].upper() for device in candidates
if device["controller"] or device["address"].upper() in self._policy_disconnected
}
reconnect_interval = CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller_candidates else RECONNECT_INTERVAL_SECONDS
if now - self._last_reconnect < reconnect_interval:
return
self._last_reconnect = now
candidate_addresses = {device["address"].upper() for device in candidates}
for address in list(self._manual_disconnect_until):
if address not in candidate_addresses or now >= self._manual_disconnect_until[address]:
self._manual_disconnect_until.pop(address, None)
for address in list(self._reconnect_backoff):
if address not in candidate_addresses:
self._reconnect_backoff.pop(address, None)
for device in candidates:
address = device["address"].upper()
controller = device["controller"] or address in self._policy_disconnected
if not device["audio"] and not controller:
continue
if suspend_controller_reconnect and controller:
continue
if now < self._manual_disconnect_until.get(address, 0.0):
continue
attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0))
if now < retry_after:
continue
try:
with self._lock:
self._client().connect(address, timeout=CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else 30.0)
self._reconnect_backoff.pop(address, None)
except Exception:
attempts += 1
delay = (CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else
min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS))
self._reconnect_backoff[address] = (attempts, now + delay)
cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s")
def maintain_connections(self) -> None: def maintain_connections(self) -> None:
while True: while True:
time.sleep(2) time.sleep(2)
now = time.monotonic()
if not self.params.get_bool("BluetoothEnabled"): if not self.params.get_bool("BluetoothEnabled"):
self._maintain_controller_offroad_policy({"offroad": self._offroad(), "devices": []}, now)
continue continue
try: try:
status = self.status() status = self.status()
if not status["available"] or not status["powered"]: if not status["available"] or not status["powered"]:
continue continue
now = time.monotonic()
self._maintain_scan(status, now) self._maintain_scan(status, now)
suspend_controller_reconnect = self._maintain_controller_offroad_policy(status, now) if self._pairing_address or now - self._last_reconnect < RECONNECT_INTERVAL_SECONDS:
self._maintain_reconnects(status, now, suspend_controller_reconnect) continue
self._last_reconnect = now
selected = str(status["selected_audio"])
candidates = [device for device in status["devices"] if device["paired"] and device["trusted"] and not device["connected"]]
candidates.sort(key=lambda device: device["address"].upper() != selected.upper())
candidate_addresses = {device["address"].upper() for device in candidates}
for address in list(self._manual_disconnect_until):
if address not in candidate_addresses or now >= self._manual_disconnect_until[address]:
self._manual_disconnect_until.pop(address, None)
for address in list(self._reconnect_backoff):
if address not in candidate_addresses:
self._reconnect_backoff.pop(address, None)
for device in candidates:
if device["audio"] or device["controller"]:
address = device["address"].upper()
if now < self._manual_disconnect_until.get(address, 0.0):
continue
_attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0))
if now < retry_after:
continue
try:
with self._lock:
self._client().connect(address)
self._reconnect_backoff.pop(address, None)
except Exception:
attempts = _attempts + 1
delay = min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS)
self._reconnect_backoff[address] = (attempts, now + delay)
cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s")
except Exception: except Exception:
cloudlog.exception("Bluetooth connection maintenance failed") cloudlog.exception("Bluetooth connection maintenance failed")
@@ -55,8 +55,6 @@ class FakeBlueZ:
self.discovering = False self.discovering = False
self.closed = False self.closed = False
self.actions = [] self.actions = []
self.connect_timeouts = []
self.connect_error = None
self.device = { self.device = {
"path": "/fake/device", "path": "/fake/device",
"address": "00:11:22:33:44:55", "address": "00:11:22:33:44:55",
@@ -93,11 +91,8 @@ class FakeBlueZ:
def pair(self, address, _device_path=None): def pair(self, address, _device_path=None):
self.actions.append(("pair", address)) self.actions.append(("pair", address))
def connect(self, address, timeout=30.0): def connect(self, address):
self.actions.append(("connect", address)) self.actions.append(("connect", address))
self.connect_timeouts.append(timeout)
if self.connect_error is not None:
raise self.connect_error
def disconnect(self, address): def disconnect(self, address):
self.actions.append(("disconnect", address)) self.actions.append(("disconnect", address))
@@ -302,7 +297,6 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect():
params = FakeParams(IsOffroad=False, BluetoothEnabled=True) params = FakeParams(IsOffroad=False, BluetoothEnabled=True)
client = FakeBlueZ() client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio()) controller = BluetoothController(params, lambda: client, FakeRadio())
controller._policy_disconnected.add(client.device["address"].upper())
controller.handle({"command": "disconnect", "address": client.device["address"]}) controller.handle({"command": "disconnect", "address": client.device["address"]})
@@ -310,7 +304,6 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect():
assert client.actions == [("disconnect", client.device["address"])] assert client.actions == [("disconnect", client.device["address"])]
assert address in controller._manual_disconnect_until assert address in controller._manual_disconnect_until
assert controller._manual_disconnect_until[address] > time.monotonic() assert controller._manual_disconnect_until[address] > time.monotonic()
assert address not in controller._policy_disconnected
def test_power_off_preserves_saved_audio_selection(): def test_power_off_preserves_saved_audio_selection():
@@ -439,85 +432,6 @@ def test_scan_stops_after_timeout():
assert not client.discovering and controller._scan_deadline == 0.0 assert not client.discovering and controller._scan_deadline == 0.0
def test_controller_offroad_disconnect_policy_is_opt_in_and_delayed():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=False)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
controller_status = {
"offroad": True,
"devices": [
{**client.device, "name": "Controller", "audio": False, "controller": True, "connected": True},
{**client.device, "address": "AA:BB:CC:DD:EE:FF", "audio": True, "controller": False, "connected": True},
],
}
assert not controller._maintain_controller_offroad_policy(controller_status, 100.0)
params.put_bool("BluetoothDisconnectControllersOffroad", True)
assert not controller._maintain_controller_offroad_policy(controller_status, 219.9)
assert client.actions == []
assert controller._maintain_controller_offroad_policy(controller_status, 220.0)
assert client.actions == [("disconnect", client.device["address"])]
assert client.device["address"].upper() in controller._policy_disconnected
assert controller._maintain_controller_offroad_policy(controller_status, 221.0)
assert client.actions == [("disconnect", client.device["address"])]
def test_controller_offroad_disconnect_policy_reconnects_onroad():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=True)
client = FakeBlueZ()
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
address = "00:11:22:33:44:55"
controller._offroad_since = 100.0
controller._policy_disconnected.add(address)
controller._reconnect_backoff[address] = (3, 500.0)
controller._last_reconnect = 210.0
disconnected_status = {
"offroad": False,
"selected_audio": "",
"devices": [{**client.device, "controller": False, "connected": False}],
}
assert not controller._maintain_controller_offroad_policy(disconnected_status, 220.0)
assert controller._offroad_since is None
assert controller._policy_disconnected == {address}
assert controller._policy_disconnect_retry_after == {}
assert address not in controller._reconnect_backoff
assert controller._last_reconnect == 0.0
controller._maintain_reconnects(disconnected_status, 220.0, False)
assert client.actions == [("connect", address)]
assert client.connect_timeouts == [5.0]
connected_status = {
**disconnected_status,
"devices": [{**client.device, "controller": False, "connected": True}],
}
controller._maintain_reconnects(connected_status, 221.0, False)
assert controller._policy_disconnected == set()
def test_controller_auto_reconnect_uses_fixed_short_retry():
params = FakeParams(IsOffroad=False, BluetoothEnabled=True)
client = FakeBlueZ()
client.connect_error = RuntimeError("Host is down")
controller = BluetoothController(params, lambda: client, FakeRadio())
controller._bluez = client
status = {
"offroad": False,
"selected_audio": "",
"devices": [{**client.device, "audio": False, "controller": True, "connected": False}],
}
controller._maintain_reconnects(status, 100.0, False)
assert controller._reconnect_backoff[client.device["address"]] == (1, 105.0)
controller._maintain_reconnects(status, 105.0, False)
assert controller._reconnect_backoff[client.device["address"]] == (2, 110.0)
assert client.connect_timeouts == [5.0, 5.0]
def test_pair_keeps_discovery_until_pair_starts(): def test_pair_keeps_discovery_until_pair_starts():
params = FakeParams(IsOffroad=True, BluetoothEnabled=True) params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ() client = FakeBlueZ()
+6 -1
View File
@@ -55,7 +55,10 @@ class MapSpeedLogger:
self.gps_location_service = get_gps_location_service(self.params) self.gps_location_service = get_gps_location_service(self.params)
self.sm = messaging.SubMaster(["deviceState", "starpilotCarState", "starpilotPlan", self.gps_location_service, "mapdOut", "modelV2"]) self.sm = messaging.SubMaster([
"carParams", "deviceState", "starpilotCarState", "starpilotPlan", "gpsLocation", "gpsLocationExternal",
"mapdOut", "modelV2",
])
@property @property
def can_make_overpass_request(self): def can_make_overpass_request(self):
@@ -252,6 +255,8 @@ class MapSpeedLogger:
return relevant_segments return relevant_segments
def log_speed_limit(self): def log_speed_limit(self):
if self.sm.updated["carParams"]:
self.gps_location_service = get_gps_location_service(self.params, self.sm["carParams"])
if not self.sm.updated[self.gps_location_service]: if not self.sm.updated[self.gps_location_service]:
return return
@@ -3,13 +3,14 @@ import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-
import { hideSidebar } from "/assets/js/utils.js" import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1" import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15" import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-3" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
import { DoorControl } from "/assets/components/tools/doors.js" import { DoorControl } from "/assets/components/tools/doors.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
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"
@@ -20,7 +21,7 @@ import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1"
import { SentryMode } from "/assets/components/tools/sentry.js" import { SentryMode } from "/assets/components/tools/sentry.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a"
import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-6" import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-5"
import { LivePlots } from "/assets/components/tools/plots.js" import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js" import { TestingGround } from "/assets/components/tools/testing_ground.js"
@@ -90,6 +91,7 @@ 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,6 +18,7 @@ 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,24 +342,6 @@
margin-bottom: var(--margin-sm); margin-bottom: var(--margin-sm);
} }
.ds-unit-note {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-muted);
display: flex;
font-size: var(--font-size-sm);
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
padding: var(--padding-sm) var(--padding-base);
}
.ds-unit-note i,
.ds-unit-note strong {
color: var(--main-fg);
}
/* ――― Empty Filter State ――― */ /* ――― Empty Filter State ――― */
.ds-empty { .ds-empty {
color: var(--text-muted); color: var(--text-muted);
@@ -1,5 +1,4 @@
import { html, reactive } from "/assets/vendor/arrow-core.js" import { html, reactive } from "/assets/vendor/arrow-core.js"
import { formatNumericParamValue, resolveVehicleUnitParam } from "/assets/mobile/js/params.js"
const endpointOptionsCache = {} const endpointOptionsCache = {}
const endpointOptionsInflight = {} const endpointOptionsInflight = {}
@@ -102,11 +101,9 @@ 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)
if (allowedMakes && !allowedMakes.some(make => normalizeVehicleMake(make) === selectedMake)) return false return allowedMakes.some(make => normalizeVehicleMake(make) === selectedMake)
const excludedMakes = param.excluded_vehicle_makes || []
return !excludedMakes.some(make => normalizeVehicleMake(make) === selectedMake)
} }
function matchesSettingValueCondition(param) { function matchesSettingValueCondition(param) {
@@ -451,6 +448,40 @@ async function fetchLayoutAndParams() {
scheduleSyncInputs() scheduleSyncInputs()
} }
function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (Number.isNaN(v)) return val
if (key === "SwitchbackModeCooldown") {
if (v === 0) return "Off"
return v === 1 ? "1 min" : `${v} min`
}
if (key === "DeviceShutdown") {
return v === 1 ? "1 hour" : `${v} hours`
}
const volumeKeys = [
"BelowSteerSpeedVolume", "DisengageVolume", "EngageVolume", "PromptVolume",
"PromptDistractedVolume", "RefuseVolume",
"WarningImmediateVolume", "WarningSoftVolume",
]
if (key && volumeKeys.includes(key)) {
if (v === 0) return "Muted"
if (v === 101) return "Auto"
return `${v}%`
}
if (precisionInt !== undefined && precisionInt !== null) {
return Number(v.toFixed(precisionInt)).toString()
}
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
const dec = stepStr.split(".")[1].length
return Number(v.toFixed(dec)).toString()
}
function formatReadoutValue(p) { function formatReadoutValue(p) {
const raw = state.values[p.key] const raw = state.values[p.key]
const v = parseFloat(raw) const v = parseFloat(raw)
@@ -474,7 +505,6 @@ function formatStepValue(step, precision) {
} }
function numericBounds(param) { function numericBounds(param) {
param = resolveVehicleUnitParam(param, state.values)
const defaultBounds = { const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100), max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
@@ -922,7 +952,13 @@ function syncNumericDisplay(param, rawValue) {
const displayEl = document.getElementById(`ds-display-${param.key}`) const displayEl = document.getElementById(`ds-display-${param.key}`)
if (!displayEl) return if (!displayEl) return
displayEl.textContent = formatNumericParamValue(param, rawValue, state.values) const bounds = numericBounds(param)
displayEl.textContent = formatSliderValue(
rawValue,
String(bounds.step),
param.precision,
param.key,
)
} }
async function updateNumericParam(param, numericValue, options = {}) { async function updateNumericParam(param, numericValue, options = {}) {
@@ -1275,9 +1311,10 @@ function matchesFilter(p) {
if (!state.filter) return true if (!state.filter) return true
if (isGroupParam(p)) return false if (isGroupParam(p)) return false
const q = state.filter.toLowerCase() const q = state.filter.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, state.values) const label = String(p.label || "").toLowerCase()
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms] const key = String(p.key || "").toLowerCase()
.some(value => String(value || "").toLowerCase().includes(q)) const description = String(p.description || "").toLowerCase()
return label.includes(q) || key.includes(q) || description.includes(q)
} }
function clearSearchFilter() { function clearSearchFilter() {
@@ -1339,7 +1376,8 @@ function formatFlmValue(param, value) {
if (value === undefined || value === null) return "not set" if (value === undefined || value === null) return "not set"
if (param.data_type === "bool") return value ? "On" : "Off" if (param.data_type === "bool") return value ? "On" : "Off"
if (param.ui_type === "numeric") { if (param.ui_type === "numeric") {
return formatNumericParamValue(param, value, state.values) const bounds = numericBounds(param)
return formatSliderValue(value, String(bounds.step), param.precision, param.key)
} }
return String(value) return String(value)
} }
@@ -1524,8 +1562,6 @@ function renderSettingRow(p) {
return "" return ""
} }
p = resolveVehicleUnitParam(p, state.values)
const isNumeric = p.ui_type === "numeric" const isNumeric = p.ui_type === "numeric"
const isSlider = isNumeric && p.control === "slider" const isSlider = isNumeric && p.control === "slider"
const isText = p.ui_type === "text" const isText = p.ui_type === "text"
@@ -1568,8 +1604,8 @@ function renderSettingRow(p) {
@input="${(event) => previewSliderParam(p, event.currentTarget.value)}" @input="${(event) => previewSliderParam(p, event.currentTarget.value)}"
@change="${(event) => commitSliderParam(p, event.currentTarget.value)}" /> @change="${(event) => commitSliderParam(p, event.currentTarget.value)}" />
<div class="ds-slider-scale"> <div class="ds-slider-scale">
<span>${formatNumericParamValue(p, numericBounds(p).min, state.values)}</span> <span>${formatSliderValue(numericBounds(p).min, String(numericBounds(p).step), p.precision, p.key)}</span>
<span>${formatNumericParamValue(p, numericBounds(p).max, state.values)}</span> <span>${formatSliderValue(numericBounds(p).max, String(numericBounds(p).step), p.precision, p.key)}</span>
</div> </div>
<button <button
class="ds-reset-btn" class="ds-reset-btn"
@@ -1595,10 +1631,10 @@ function renderSettingRow(p) {
const updating = isNumericUpdating(p.key) const updating = isNumericUpdating(p.key)
const defaultNumeric = resolveDefaultNumericValue(p, bounds) const defaultNumeric = resolveDefaultNumericValue(p, bounds)
const defaultLabel = defaultNumeric !== null const defaultLabel = defaultNumeric !== null
? formatNumericParamValue(p, defaultNumeric, state.values) ? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key)
: "N/A" : "N/A"
const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon
const stepLabel = p.key === "DeviceShutdown" ? "1 hour" : `${formatStepValue(bounds.step, precision)}${p.unit || ""}` const stepLabel = p.key === "DeviceShutdown" ? "1 hour" : formatStepValue(bounds.step, precision)
return html` return html`
<div class="ds-stepper"> <div class="ds-stepper">
<button <button
@@ -1606,7 +1642,7 @@ function renderSettingRow(p) {
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}" disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}"
@click="${() => stepNumericParam(p, -1)}">-</button> @click="${() => stepNumericParam(p, -1)}">-</button>
<div class="ds-stepper-meta"> <div class="ds-stepper-meta">
<span>${formatNumericParamValue(p, bounds.min, state.values)} to ${formatNumericParamValue(p, bounds.max, state.values)}</span> <span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
<span class="ds-step-value">Step: ${stepLabel} per click</span> <span class="ds-step-value">Step: ${stepLabel} per click</span>
<span class="ds-default-value">Default: ${defaultLabel}</span> <span class="ds-default-value">Default: ${defaultLabel}</span>
<div class="ds-manual-row"> <div class="ds-manual-row">
@@ -1754,7 +1790,8 @@ function renderSettingRow(p) {
if (isColor) return formatColorDisplayValue(p) if (isColor) return formatColorDisplayValue(p)
if (isReadout) return formatReadoutValue(p) if (isReadout) return formatReadoutValue(p)
const currentValue = state.sliderPreviewValues[p.key] ?? state.values[p.key] const currentValue = state.sliderPreviewValues[p.key] ?? state.values[p.key]
return currentValue !== undefined ? formatNumericParamValue(p, currentValue, state.values) : ".." const bounds = numericBounds(p)
return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".."
}}</span>` : ""} }}</span>` : ""}
</div> </div>
@@ -151,11 +151,6 @@
color: var(--color-black); color: var(--color-black);
} }
.ml-button-danger {
background: rgba(224, 85, 119, 0.12);
color: var(--danger-fg);
}
.ml-button:disabled { .ml-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
@@ -12,6 +12,7 @@ const state = reactive({
download: {}, download: {},
models: [], models: [],
summary: {}, summary: {},
manifest: { version: "unknown", shortcomings: [], opportunities: [] },
}) })
let initialized = false let initialized = false
@@ -26,33 +27,31 @@ function modelLabel(modelId) {
return modelById(modelId)?.label || modelId || "not selected" return modelById(modelId)?.label || modelId || "not selected"
} }
function availableModels() { function readyModels() {
return state.models.filter(model => model.modelLabArtifactAvailable) return state.models.filter(model => model.modelLabArtifactAvailable)
} }
function downloadedModels() {
return availableModels().filter(model => model.modelLabArtifactInstalled)
}
function candidateModels(role) { function candidateModels(role) {
const downloaded = downloadedModels() const ready = readyModels()
if (role !== "longitudinal") return downloaded if (role !== "longitudinal") return ready
const lateral = modelById(state.configuration.lateralModel) const lateral = modelById(state.configuration.lateralModel)
if (!lateral) return downloaded if (!lateral) return ready
return downloaded.filter(model => model.value !== lateral.value) return ready.filter(model => model.value !== lateral.value)
} }
function selectionError() { function selectionError() {
if (!state.chestnutReady) return "Connect a firmware-ready Chestnut first."
if (state.isOnroad) return "Park before changing the laboratory pair." if (state.isOnroad) return "Park before changing the laboratory pair."
if (downloadedModels().length < 2) return "Download at least two eGPU variants before composing a pair."
const lateral = modelById(state.configuration.lateralModel) const lateral = modelById(state.configuration.lateralModel)
const longitudinal = modelById(state.configuration.longitudinalModel) const longitudinal = modelById(state.configuration.longitudinalModel)
if (!lateral || !longitudinal) return "Choose two downloaded eGPU variants." if (!lateral || !longitudinal) return "Choose two small models with published Chestnut artifacts."
if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different." if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different."
if (!lateral.modelLabArtifactAvailable || !longitudinal.modelLabArtifactAvailable) {
return "Both models need a precompiled AMD artifact in the manifest."
}
if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) { if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) {
return "Download both eGPU variants first." return "Prepare both precompiled AMD artifacts first."
} }
if (!state.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
} }
@@ -76,14 +75,17 @@ function applyPayload(payload) {
state.download = payload?.download && typeof payload.download === "object" ? payload.download : {} state.download = payload?.download && typeof payload.download === "object" ? payload.download : {}
state.models = Array.isArray(payload?.models) ? payload.models : [] state.models = Array.isArray(payload?.models) ? payload.models : []
state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {} state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {}
state.manifest = payload?.manifest && typeof payload.manifest === "object"
? payload.manifest
: { version: "unknown", shortcomings: [], opportunities: [] }
state.error = String(payload?.configurationError || "") state.error = String(payload?.configurationError || "")
const downloaded = downloadedModels() const ready = readyModels()
if (!downloaded.some(model => model.value === state.configuration.lateralModel)) { if (!modelById(state.configuration.lateralModel) && ready.length > 0) {
state.configuration.lateralModel = downloaded[0]?.value || "" state.configuration.lateralModel = ready[0].value
} }
if (!downloaded.some(model => model.value === state.configuration.longitudinalModel)) { if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) {
state.configuration.longitudinalModel = downloaded.find(model => ( state.configuration.longitudinalModel = ready.find(model => (
model.value !== state.configuration.lateralModel model.value !== state.configuration.lateralModel
))?.value || "" ))?.value || ""
} }
@@ -155,7 +157,7 @@ async function prepareModel(modelId) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId }), body: JSON.stringify({ model: modelId }),
}) })
state.message = String(payload.message || "eGPU variant download queued.") state.message = String(payload.message || "Chestnut artifact download queued.")
await refresh() await refresh()
} catch (error) { } catch (error) {
state.error = error?.message || String(error) state.error = error?.message || String(error)
@@ -164,29 +166,6 @@ async function prepareModel(modelId) {
} }
} }
async function deleteModel(modelId) {
if (state.saving || !modelId) return
const model = modelById(modelId)
if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return
state.saving = true
state.error = ""
state.message = ""
try {
const payload = await requestJson("/api/model-laboratory/artifact", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId }),
})
selectionDirty = false
applyPayload(payload)
state.message = String(payload.message || "eGPU variant deleted.")
} catch (error) {
state.error = error?.message || String(error)
} finally {
state.saving = false
}
}
function bindControls() { function bindControls() {
const lateral = document.getElementById("ml-lateral-model") const lateral = document.getElementById("ml-lateral-model")
const longitudinal = document.getElementById("ml-longitudinal-model") const longitudinal = document.getElementById("ml-longitudinal-model")
@@ -198,11 +177,6 @@ function bindControls() {
button.dataset.bound = "1" button.dataset.bound = "1"
button.addEventListener("click", () => prepareModel(button.dataset.mlDownload)) button.addEventListener("click", () => prepareModel(button.dataset.mlDownload))
}) })
document.querySelectorAll("[data-ml-delete]").forEach(button => {
if (button.dataset.bound === "1") return
button.dataset.bound = "1"
button.addEventListener("click", () => deleteModel(button.dataset.mlDelete))
})
if (lateral) { if (lateral) {
lateral.value = state.configuration.lateralModel lateral.value = state.configuration.lateralModel
@@ -252,15 +226,15 @@ function ensurePolling() {
return return
} }
await refresh() await refresh()
pollHandle = setTimeout(poll, state.download?.model ? 1000 : 5000) pollHandle = setTimeout(poll, 5000)
} }
pollHandle = setTimeout(poll, 5000) pollHandle = setTimeout(poll, 5000)
} }
function renderModel(model) { function renderModel(model) {
const artifactStatus = model.modelLabArtifactInstalled const artifactStatus = model.modelLabArtifactInstalled
? "eGPU variant downloaded" ? "AMD ready"
: "eGPU variant not downloaded" : model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published"
return html` return html`
<div class="ml-model"> <div class="ml-model">
<div> <div>
@@ -274,15 +248,8 @@ function renderModel(model) {
${artifactStatus} ${artifactStatus}
</span> </span>
${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html` ${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}"> <button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad}">
${() => state.download?.model === model.value Prepare for Chestnut
? `Downloading · ${state.download?.progress || "starting…"}`
: "Download eGPU variant"}
</button>
` : ""}
${model.modelLabArtifactInstalled ? html`
<button class="ml-button ml-button-danger" data-ml-delete="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}">
Delete eGPU variant
</button> </button>
` : ""} ` : ""}
</div> </div>
@@ -320,22 +287,11 @@ export function ModelLaboratory() {
${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""} ${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""}
${() => !state.loading ? html` ${() => !state.loading ? html`
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.</p>
<p>${() => `${state.summary.ready || 0} downloaded · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => availableModels().map(renderModel)}</div>
</section>
<section class="ml-card"> <section class="ml-card">
<div class="ml-card-heading"> <div class="ml-card-heading">
<div> <div>
<h3>Compose a pair</h3> <h3>Compose a pair</h3>
<p>Choose from downloaded eGPU variant combinations below.</p> <p>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p>
</div> </div>
<span class="${() => `ml-state ${state.configuration.enabled ? "is-enabled" : ""}`}"> <span class="${() => `ml-state ${state.configuration.enabled ? "is-enabled" : ""}`}">
${() => state.configuration.enabled ? "Enabled" : "Disabled"} ${() => state.configuration.enabled ? "Enabled" : "Disabled"}
@@ -408,6 +364,20 @@ export function ModelLaboratory() {
<p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p> <p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
</section> </section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>${() => `${state.summary.ready || 0} ready to pair · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => readyModels().map(renderModel)}</div>
<div class="ml-note">
Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma.
A normal installed model may still need its separate Chestnut artifact.
</div>
</section>
` : ""} ` : ""}
</div> </div>
` `
@@ -37,44 +37,13 @@
.wheelCard, .wheelCard,
.wheelNotice, .wheelNotice,
.wheelError, .wheelError,
.wheelDeviceSummary, .wheelDeviceSummary {
.wheelPolicy {
background: var(--sidebar-bg); background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color); border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: 16px; padding: 16px;
} }
.wheelPolicy {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
cursor: pointer;
}
.wheelPolicy span,
.wheelPolicy small {
display: block;
}
.wheelPolicy small {
margin-top: 5px;
opacity: 0.68;
}
.wheelPolicy input {
width: 22px;
height: 22px;
flex: 0 0 auto;
accent-color: #8b6cc5;
}
.wheelPolicy:has(input:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
.wheelSlotGrid, .wheelSlotGrid,
.wheelControllerGrid, .wheelControllerGrid,
.wheelMappings { .wheelMappings {
@@ -11,7 +11,6 @@ const state = reactive({
slots: [], slots: [],
controllerSlots: [], controllerSlots: [],
controllerOptions: [], controllerOptions: [],
disconnectControllersOffroad: false,
speedUnit: "mph", speedUnit: "mph",
speedMinimum: 5, speedMinimum: 5,
speedMaximum: 90, speedMaximum: 90,
@@ -38,7 +37,6 @@ async function refresh() {
state.slots = Array.isArray(payload.slots) ? payload.slots : [] state.slots = Array.isArray(payload.slots) ? payload.slots : []
state.controllerSlots = Array.isArray(payload.controller_slots) ? payload.controller_slots : [] state.controllerSlots = Array.isArray(payload.controller_slots) ? payload.controller_slots : []
state.controllerOptions = Array.isArray(payload.controller_options) ? payload.controller_options : [] state.controllerOptions = Array.isArray(payload.controller_options) ? payload.controller_options : []
state.disconnectControllersOffroad = !!payload.disconnect_controllers_offroad
state.speedUnit = typeof payload.speed_unit === "string" ? payload.speed_unit : "mph" state.speedUnit = typeof payload.speed_unit === "string" ? payload.speed_unit : "mph"
state.speedMinimum = Number(payload.speed_minimum || 5) state.speedMinimum = Number(payload.speed_minimum || 5)
state.speedMaximum = Number(payload.speed_maximum || 90) state.speedMaximum = Number(payload.speed_maximum || 90)
@@ -267,16 +265,6 @@ export function WheelControls() {
${() => !state.loading && !state.available && state.mappings.length ? html`<div class="wheelNotice">The wheel control service is starting.</div>` : ""} ${() => !state.loading && !state.available && state.mappings.length ? html`<div class="wheelNotice">The wheel control service is starting.</div>` : ""}
${() => state.testing ? testPanel() : ""} ${() => state.testing ? testPanel() : ""}
<label class="wheelPolicy">
<span>
<strong>Disconnect controllers when offroad</strong>
<small>After two minutes offroad, paired controllers disconnect to save battery and reconnect when the car starts. Bluetooth and audio-only devices stay connected.</small>
</span>
<input type="checkbox" checked="${() => state.disconnectControllersOffroad}"
disabled="${() => !state.offroad || !!state.busy}"
@change="${event => request("offroad-disconnect", { enabled: event.currentTarget.checked })}" />
</label>
<div class="wheelDeviceSummary"> <div class="wheelDeviceSummary">
<div class="wheelDeviceHeading"> <div class="wheelDeviceHeading">
<strong>Connected input devices</strong> <strong>Connected input devices</strong>
@@ -138,13 +138,10 @@
.dh-donut { .dh-donut {
--dh-value: 0; --dh-value: 0;
align-items: center; display: grid;
display: flex;
flex-direction: column;
flex: 0 0 auto; flex: 0 0 auto;
gap: 3px;
height: 116px; height: 116px;
justify-content: center; place-items: center;
position: relative; position: relative;
width: 116px; width: 116px;
} }
@@ -592,66 +592,6 @@ ul { list-style: none; margin: 0; padding: 0; }
transition: transform var(--motion-fast), box-shadow var(--motion-fast); transition: transform var(--motion-fast), box-shadow var(--motion-fast);
} }
.gx-unit-note {
align-items: center;
background: var(--primary-container);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
color: var(--on-primary-container);
display: flex;
font-size: var(--fs-sm);
gap: var(--sp-2);
margin: 0 0 var(--sp-4);
padding: var(--sp-3) var(--sp-4);
}
.gx-unit-note i {
color: var(--primary);
font-size: 1.2rem;
}
.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);
} }
@@ -809,13 +749,6 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-slider-row .gx-row__value { text-align: left; min-width: 0; } .gx-slider-row .gx-row__value { text-align: left; min-width: 0; }
.gx-slider-row .gx-slider-reset { align-self: flex-end; } .gx-slider-row .gx-slider-reset { align-self: flex-end; }
.gx-slider-meta {
color: var(--text-muted);
display: flex;
font-size: var(--fs-xs);
justify-content: space-between;
}
input[type="range"].gx-slider { input[type="range"].gx-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@@ -1564,4 +1497,4 @@ input[type="color"].gx-color {
.gx-menu-btn { display: inline-flex; } .gx-menu-btn { display: inline-flex; }
.gx-back-btn { display: none; } .gx-back-btn { display: none; }
.gx-content { padding-bottom: var(--sp-6); } .gx-content { padding-bottom: var(--sp-6); }
} }
@@ -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="Galaxy"> <meta name="apple-mobile-web-app-title" content="Big Dipper">
<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>Galaxy</title> <title>Big Dipper</title>
</head> </head>
<body> <body>
@@ -136,7 +136,6 @@ export const api = {
getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) }, getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) },
saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) }, saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) },
prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) }, prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) },
deleteModelLabArtifact(model) { return request("/api/model-laboratory/artifact", { method: "DELETE", data: { model } }) },
getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) }, getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) },
getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) }, getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) },
@@ -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,13 +43,12 @@ 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": Cameras, "/sentry": Sentry,
"/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">Galaxy</span> <span class="gx-appbar__title">Big Dipper</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="Galaxy logo" /> <img class="gx-logo" src="/assets/images/main_logo.png" alt="Big Dipper logo" />
<span class="gx-drawer-title">Galaxy</span> <span class="gx-drawer-title">Big Dipper</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,7 +20,6 @@ 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, formatNumericParamValue, formatReadoutValue, getColorDefault, coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep, normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
resolveVehicleUnitParam, stepPrecision, stepPrecision,
} from "../params.js" } from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js" import { FavoritesEditor } from "./FavoritesEditor.js"
@@ -12,7 +12,6 @@ export const GalaxyToggleCard = {
props: { props: {
param: { type: Object, required: true }, param: { type: Object, required: true },
value: { default: undefined }, value: { default: undefined },
values: { type: Object, default: () => ({}) },
locked: { type: Boolean, default: false }, locked: { type: Boolean, default: false },
manageable: { type: Boolean, default: false }, manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false }, manageOpen: { type: Boolean, default: false },
@@ -29,9 +28,8 @@ export const GalaxyToggleCard = {
} }
}, },
computed: { computed: {
displayParam() { return resolveVehicleUnitParam(this.param, this.values) }, bounds() { return numericBounds(this.param, {}) },
bounds() { return numericBounds(this.displayParam, this.values) }, precision() { return stepPrecision(this.bounds.step, this.param.precision) },
precision() { return stepPrecision(this.bounds.step, this.displayParam.precision) },
epsilon() { return numericEpsilon(this.precision) }, epsilon() { return numericEpsilon(this.precision) },
isSlider() { return this.isNumeric }, isSlider() { return this.isNumeric },
isNumeric() { return this.param.ui_type === "numeric" }, isNumeric() { return this.param.ui_type === "numeric" },
@@ -40,17 +38,11 @@ export const GalaxyToggleCard = {
currentValue() { return this.preview !== undefined ? this.preview : this.value }, currentValue() { return this.preview !== undefined ? this.preview : this.value },
displayValue() { displayValue() {
if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock" if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
if (this.isReadout) return formatReadoutValue(this.displayParam, this.value) if (this.isReadout) return formatReadoutValue(this.param, this.value)
return this.value !== undefined && this.value !== null ? formatNumericParamValue(this.displayParam, this.value, this.values) : ".." return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
}, },
sliderDisplay() { sliderDisplay() {
return this.value !== undefined ? formatNumericParamValue(this.displayParam, this.currentValue, this.values) : ".." return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
sliderRangeDisplay() {
return `${formatNumericParamValue(this.displayParam, this.bounds.min, this.values)} to ${formatNumericParamValue(this.displayParam, this.bounds.max, this.values)}`
},
sliderStepDisplay() {
return formatNumericParamValue(this.displayParam, this.bounds.step, this.values)
}, },
isColor() { return this.param.ui_type === "color" }, isColor() { return this.param.ui_type === "color" },
isAction() { return this.param.ui_type === "action" }, isAction() { return this.param.ui_type === "action" },
@@ -175,10 +167,10 @@ export const GalaxyToggleCard = {
<div> <div>
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }"> <div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
<div class="gx-row__info"> <div class="gx-row__info">
<span class="gx-row__label">{{ displayParam.label }} <span class="gx-row__label">{{ param.label }}
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span> <span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span> </span>
<span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.description }}</span> <span v-if="param.description" class="gx-row__desc">{{ param.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div> <div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
</div> </div>
@@ -198,10 +190,6 @@ export const GalaxyToggleCard = {
:value="currentValue" :disabled="locked || updating" :value="currentValue" :disabled="locked || updating"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur" @input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" /> @touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<div v-if="displayParam.unit_type" class="gx-slider-meta">
<span>{{ sliderRangeDisplay }}</span>
<span>Step: {{ sliderStepDisplay }}</span>
</div>
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button> <button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
</div> </div>
@@ -1,5 +1,5 @@
import { api } from "../api.js" import { api } from "../api.js"
import { isSettingVisible, resolveVehicleUnitParam, slugifySectionName, applyParamChange } from "../params.js" import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
import { SettingTree } from "./SettingTree.js" import { SettingTree } from "./SettingTree.js"
import { GalaxySection } from "./GalaxySection.js" import { GalaxySection } from "./GalaxySection.js"
@@ -35,9 +35,7 @@ export const ParamSections = {
matches(p) { matches(p) {
if (!this.search) return true if (!this.search) return true
const q = this.search.toLowerCase() const q = this.search.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, this.values) return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms]
.some((v) => String(v || "").toLowerCase().includes(q))
}, },
async load() { async load() {
try { try {
@@ -30,7 +30,7 @@ export const SettingTree = {
template: ` template: `
<template v-for="p in children" :key="p.key"> <template v-for="p in children" :key="p.key">
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth"> <div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)" :manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" /> @change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div> </div>
@@ -216,7 +216,6 @@ 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,7 +12,6 @@ export const WheelControls = {
loading: true, busy: "", available: false, offroad: false, learning: false, loading: true, busy: "", available: false, offroad: false, learning: false,
devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [], devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false, joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
disconnectControllersOffroad: false,
lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "", lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
} }
}, },
@@ -30,7 +29,6 @@ export const WheelControls = {
this.slots = Array.isArray(p.slots) ? p.slots : [] this.slots = Array.isArray(p.slots) ? p.slots : []
this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : [] this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : [] this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
this.disconnectControllersOffroad = !!p.disconnect_controllers_offroad
this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : "" this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null
this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0 this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0
@@ -101,18 +99,6 @@ export const WheelControls = {
<button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button> <button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button>
<button type="button" class="gx-btn gx-btn--danger" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button> <button type="button" class="gx-btn gx-btn--danger" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button>
</div> </div>
<div class="gx-row" style="margin-bottom:12px;">
<div class="gx-row__info">
<span class="gx-row__label">Disconnect controllers when offroad</span>
<span class="gx-row__desc">After two minutes offroad, paired controllers disconnect to save battery and reconnect when the car starts. Bluetooth and audio-only devices stay connected.</span>
</div>
<label class="gx-switch">
<input type="checkbox" :checked="disconnectControllersOffroad" :disabled="disabled()"
@change="request('offroad-disconnect', { enabled: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<div v-if="testing && lastTested" style="margin-bottom:12px;"> <div v-if="testing && lastTested" style="margin-bottom:12px;">
<span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span> <span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span>
<p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p> <p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p>
@@ -1,45 +1,4 @@
export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode" export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
export const VEHICLE_SPEED_UNIT_TYPE = "vehicle_speed"
const SPEED_OFFSET_RANGES = {
imperial: ["024", "2534", "3544", "4554", "5564", "6574", "7599"],
metric: ["029", "3049", "5059", "6079", "8099", "100119", "120140"],
}
export function usesMetricUnits(values = {}) {
const value = values?.IsMetric
if (value === true || value === 1) return true
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase())
}
export function vehicleSpeedUnit(values = {}) {
return usesMetricUnits(values) ? "km/h" : "mph"
}
export function resolveVehicleUnitParam(param, values = {}) {
if (!param || param.unit_type !== VEHICLE_SPEED_UNIT_TYPE) return param
const metric = usesMetricUnits(values)
const mode = metric ? "metric" : "imperial"
const resolved = {
...param,
unit: ` ${vehicleSpeedUnit(values)}`,
unit_search_terms: "metric imperial mph km/h vehicle speed units",
}
for (const field of ["min", "max", "step", "precision"]) {
const override = param[`${mode}_${field}`]
if (override !== undefined && override !== null) resolved[field] = override
}
if (Number.isInteger(param.unit_range_index)) {
const range = SPEED_OFFSET_RANGES[mode][param.unit_range_index]
if (range) {
resolved.label = `Speed Offset (${range} ${vehicleSpeedUnit(values)})`
resolved.description = `How much to offset posted speed limits between ${range} ${vehicleSpeedUnit(values)}.`
}
}
return resolved
}
const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"]) const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"]) const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
@@ -129,8 +88,7 @@ export function countAdvancedHiddenByDeveloperMode(layout, values) {
return count return count
} }
export function numericBounds(param, values = {}) { export function numericBounds(param, values) {
param = resolveVehicleUnitParam(param, values)
const defaultBounds = { const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0), min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100), max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
@@ -236,13 +194,6 @@ export function formatSliderValue(val, stepStr, precisionInt, key) {
return Number(v.toFixed(dec)).toString() return Number(v.toFixed(dec)).toString()
} }
export function formatNumericParamValue(param, value, values = {}) {
const resolved = resolveVehicleUnitParam(param, values)
const bounds = numericBounds(resolved, values)
const formatted = formatSliderValue(value, String(bounds.step), resolved.precision, resolved.key)
return resolved.unit && formatted !== "--" ? `${formatted}${resolved.unit}` : formatted
}
export function formatReadoutValue(p, value) { export function formatReadoutValue(p, value) {
const raw = value const raw = value
const parsed = parseFloat(raw) const parsed = parseFloat(raw)
@@ -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", "/bluetooth", "/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", "/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]
@@ -1,15 +0,0 @@
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { GalaxySection } from "../components/GalaxySection.js"
export const Bluetooth = {
name: "Bluetooth",
components: { BluetoothPanel, GalaxySection },
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Bluetooth</h2>
<GalaxySection title="Bluetooth Devices" icon="bi-bluetooth" :collapsible="false">
<BluetoothPanel />
</GalaxySection>
</div>
`,
}
@@ -1,20 +1,18 @@
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: { Sentry, Vasm, Pip, GalaxyTabs }, components: { Vasm, Pip, GalaxyTabs },
setup() { setup() {
return useTabRouting("/cameras", { sentry: "sentry", vasm: "vasm", pip: "pip" }) return useTabRouting("/cameras", { vasm: "vasm", pip: "pip" })
}, },
data() { return { TABS } }, data() { return { TABS } },
template: ` template: `
@@ -22,11 +20,7 @@ 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 === 'sentry'"> <template v-if="tab === 'vasm'">
<Sentry />
</template>
<template v-else-if="tab === 'vasm'">
<Vasm :embedded="true" /> <Vasm :embedded="true" />
</template> </template>
@@ -4,13 +4,6 @@ import { PwaInstallSection, isFirestarOrigin } from "../components/PwaInstallSec
const isTunnel = () => isFirestarOrigin() const isTunnel = () => isFirestarOrigin()
function localDeviceUrl(ip) {
const raw = String(ip || "").trim()
if (!raw || raw === "unknown") return ""
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
return `http://${host}:8082`
}
export const Galaxy = { export const Galaxy = {
name: "Galaxy", name: "Galaxy",
components: { PwaInstallSection }, components: { PwaInstallSection },
@@ -21,17 +14,10 @@ export const Galaxy = {
url: "", url: "",
password: "", password: "",
submitting: false, submitting: false,
localUrl: "",
} }
}, },
async mounted() { async mounted() {
if (this.isTunnel) { if (this.isTunnel) return
try {
const status = await api.getDeviceStatus()
this.localUrl = localDeviceUrl(status?.lanIp)
} catch (e) {}
return
}
try { try {
const data = await api.getGalaxyStatus() const data = await api.getGalaxyStatus()
this.paired = !!data?.paired this.paired = !!data?.paired
@@ -90,11 +76,7 @@ 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> <span>Galaxy pairing requires a direct connection. Connect to your device's local network to use this feature.</span>
Galaxy pairing requires a direct connection. If you are on the same local network, connect here:
<a v-if="localUrl" :href="localUrl" style="color:inherit; font-weight:var(--fw-bold); overflow-wrap:anywhere;">{{ localUrl }}</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: `conic-gradient(${segments.join(", ")})`, rows } return { hasModels: true, style: `background: 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="{ backgroundImage: modelView.style }" role="img" aria-label="Model usage share"></div> <div class="dh-chart-ring" :style="{ background: modelView.style }"></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", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux" }) return useTabRouting("/logs", { errors: "errors", tmux: "tmux", troubleshoot: "troubleshoot" })
}, },
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,17 +14,13 @@ export const ModelLaboratory = {
isOnroad: false, isOnroad: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" }, configuration: { enabled: false, lateralModel: "", longitudinalModel: "" },
runtime: {}, runtime: {},
download: {},
summary: {}, summary: {},
models: [], models: [],
} }
}, },
computed: { computed: {
availableModels() {
return this.models.filter((m) => m && m.modelLabArtifactAvailable)
},
readyModels() { readyModels() {
return this.availableModels.filter((m) => m.modelLabArtifactInstalled) return this.models.filter((m) => m && m.modelLabArtifactAvailable)
}, },
candidates() { candidates() {
const ready = this.readyModels const ready = this.readyModels
@@ -32,16 +28,18 @@ export const ModelLaboratory = {
return ready.filter((m) => !lat || m.value !== lat) return ready.filter((m) => !lat || m.value !== lat)
}, },
selectionError() { selectionError() {
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut first."
if (this.isOnroad) return "Park before changing the laboratory pair." if (this.isOnroad) return "Park before changing the laboratory pair."
if (this.readyModels.length < 2) return "Download at least two eGPU variants before composing a pair."
const lat = this.modelById(this.configuration.lateralModel) const lat = this.modelById(this.configuration.lateralModel)
const lon = this.modelById(this.configuration.longitudinalModel) const lon = this.modelById(this.configuration.longitudinalModel)
if (!lat || !lon) return "Choose two downloaded eGPU variants." if (!lat || !lon) return "Choose two small models with published Chestnut artifacts."
if (lat.value === lon.value) return "Lateral and longitudinal models must be different." if (lat.value === lon.value) return "Lateral and longitudinal models must be different."
if (!lat.modelLabArtifactAvailable || !lon.modelLabArtifactAvailable) {
return "Both models need a precompiled AMD artifact in the manifest."
}
if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) { if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) {
return "Download both eGPU variants first." return "Prepare both precompiled AMD artifacts first."
} }
if (!this.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair."
return "" return ""
}, },
runtimeState() { runtimeState() {
@@ -50,7 +48,7 @@ export const ModelLaboratory = {
}, },
}, },
created() { created() {
this.poll = usePolling(() => this.refresh(), { interval: 2000 }) this.poll = usePolling(() => this.refresh(), { interval: 5000 })
this.poll.start() this.poll.start()
}, },
beforeUnmount() { beforeUnmount() {
@@ -64,8 +62,9 @@ export const ModelLaboratory = {
return this.modelById(id)?.label || id || "not selected" return this.modelById(id)?.label || id || "not selected"
}, },
artifactStatus(m) { artifactStatus(m) {
if (m.modelLabArtifactInstalled) return { text: "eGPU variant downloaded", good: true } if (m.modelLabArtifactInstalled) return { text: "AMD ready", good: true }
return { text: "eGPU variant not downloaded", good: false } if (m.modelLabArtifactAvailable) return { text: "AMD download needed", good: false }
return { text: "AMD not published", good: false }
}, },
async refresh() { async refresh() {
try { try {
@@ -83,7 +82,6 @@ export const ModelLaboratory = {
this.isOnroad = Boolean(payload.isOnroad) this.isOnroad = Boolean(payload.isOnroad)
this.error = String(payload.configurationError || "") this.error = String(payload.configurationError || "")
this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {} this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {}
this.download = payload.download && typeof payload.download === "object" ? payload.download : {}
this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {} this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {}
this.models = Array.isArray(payload.models) ? payload.models : [] this.models = Array.isArray(payload.models) ? payload.models : []
const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {} const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {}
@@ -97,10 +95,10 @@ export const ModelLaboratory = {
}, },
normalizeSelection() { normalizeSelection() {
const ready = this.readyModels const ready = this.readyModels
if (!ready.some((m) => m.value === this.configuration.lateralModel)) { if (!this.modelById(this.configuration.lateralModel) && ready.length) {
this.configuration.lateralModel = ready[0]?.value || "" this.configuration.lateralModel = ready[0].value
} }
if (!ready.some((m) => m.value === this.configuration.longitudinalModel)) { if (!this.modelById(this.configuration.longitudinalModel) && ready.length > 1) {
const lon = ready.find((m) => m.value !== this.configuration.lateralModel) const lon = ready.find((m) => m.value !== this.configuration.lateralModel)
this.configuration.longitudinalModel = lon?.value || "" this.configuration.longitudinalModel = lon?.value || ""
} }
@@ -145,8 +143,8 @@ export const ModelLaboratory = {
this.message = "" this.message = ""
try { try {
const payload = await api.prepareModelLabArtifact(modelId) const payload = await api.prepareModelLabArtifact(modelId)
this.message = String(payload?.message || "eGPU variant download queued.") this.message = String(payload?.message || "Chestnut artifact download queued.")
showSnackbar("eGPU variant download queued", "info") showSnackbar("Chestnut artifact download queued", "info")
await this.refresh() await this.refresh()
} catch (e) { } catch (e) {
this.error = e?.message || String(e) this.error = e?.message || String(e)
@@ -154,25 +152,6 @@ export const ModelLaboratory = {
this.saving = false this.saving = false
} }
}, },
async deleteModel(modelId) {
if (this.saving || !modelId) return
const model = this.modelById(modelId)
if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return
this.saving = true
this.error = ""
this.message = ""
try {
const payload = await api.deleteModelLabArtifact(modelId)
this.dirty = false
this.applyPayload(payload)
this.message = String(payload?.message || "eGPU variant deleted.")
showSnackbar("eGPU variant deleted", "info")
} catch (e) {
this.error = e?.message || String(e)
} finally {
this.saving = false
}
},
}, },
template: ` template: `
<div class="gx-view"> <div class="gx-view">
@@ -198,41 +177,12 @@ export const ModelLaboratory = {
</div> </div>
</div> </div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} downloaded · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<div style="padding: 0 var(--sp-4) var(--sp-3); color:var(--text-muted); font-size:var(--fs-sm);">
Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.
</div>
<article v-for="m in availableModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="!m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad || !!download.model" @click="prepareModel(m.value)">
{{ download.model === m.value ? 'Downloading · ' + (download.progress || 'starting…') : 'Download eGPU variant' }}
</button>
<button v-else type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="saving || isOnroad || !!download.model" @click="deleteModel(m.value)">
Delete eGPU variant
</button>
</div>
</article>
</div>
<div class="gx-card"> <div class="gx-card">
<div class="gx-section__header"> <div class="gx-section__header">
<i class="bi bi-collection"></i> <i class="bi bi-collection"></i>
<span class="gx-section__title">Compose a pair</span> <span class="gx-section__title">Compose a pair</span>
<span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span> <span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span>
</div> </div>
<div style="padding: 0 var(--sp-4); color:var(--text-muted); font-size:var(--fs-sm);">Choose from downloaded eGPU variant combinations below.</div>
<div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);"> <div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);">
<label style="display:grid; gap:4px;"> <label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Lateral model</strong> <strong style="font-size:var(--fs-sm);">Lateral model</strong>
@@ -284,6 +234,30 @@ export const ModelLaboratory = {
</div> </div>
</div> </div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} ready to pair · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<article v-for="m in readyModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad" @click="prepareModel(m.value)">
Prepare for Chestnut
</button>
</div>
</article>
<div style="padding: var(--sp-3);">
<p class="gx-row__desc" style="margin:0;">Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. A normal installed model may still need its separate Chestnut artifact.</p>
</div>
</div>
</template> </template>
</div> </div>
`, `,
@@ -52,13 +52,6 @@ function normalizeRoute(r) {
} }
} }
function localDeviceUrl(ip) {
const raw = String(ip || "").trim()
if (!raw || raw === "unknown") return ""
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
return `http://${host}:8082`
}
export const Recordings = { export const Recordings = {
name: "Recordings", name: "Recordings",
components: { GalaxyTabs, GxNotice }, components: { GalaxyTabs, GxNotice },
@@ -82,7 +75,6 @@ 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: "",
@@ -343,14 +335,7 @@ export const Recordings = {
}, },
}, },
async mounted() { async mounted() {
if (this.onFirestar) { if (!this.onFirestar) await this.loadRoutes()
try {
const status = await api.getDeviceStatus()
this.localUrl = localDeviceUrl(status?.lanIp)
} catch (e) {}
return
}
await this.loadRoutes()
}, },
beforeUnmount() { beforeUnmount() {
this.controller?.abort() this.controller?.abort()
@@ -518,11 +503,8 @@ 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"
Recordings are unavailable via Galaxy for bandwidth reasons. If you are on the same local network, connect here: text="Loading recordings requires a direct connection. Connect to your device's local network to use this feature." />
<a v-if="localUrl" :href="localUrl" style="color:inherit; font-weight:var(--fw-bold); overflow-wrap:anywhere;">{{ localUrl }}</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,
resolveVehicleUnitParam, slugifySectionName, 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,9 +82,7 @@ export const Settings = {
matchesFilter(p) { matchesFilter(p) {
if (!this.searchTerm) return true if (!this.searchTerm) return true
const q = this.searchTerm.toLowerCase() const q = this.searchTerm.toLowerCase()
const displayParam = resolveVehicleUnitParam(p, this.values) return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
return [displayParam.label, displayParam.key, displayParam.description, displayParam.unit, displayParam.unit_search_terms]
.some((v) => String(v || "").toLowerCase().includes(q))
}, },
selectSection(slug) { selectSection(slug) {
if (slug !== this.activeSectionSlug) navigate("/settings/" + slug) if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
@@ -134,7 +132,7 @@ export const Settings = {
<template v-for="section in searchResults" :key="section.slug"> <template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false"> <GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<template v-for="p in section.matches" :key="p.key"> <template v-for="p in section.matches" :key="p.key">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" <GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
@change="onParamChange" /> @change="onParamChange" />
</template> </template>
</GalaxySection> </GalaxySection>
@@ -30,17 +30,11 @@ export const SystemTools = {
profileBusy: "", profileBusy: "",
} }
}, },
created() { created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
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.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running }, updateAvailable() { return !!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
@@ -59,7 +53,6 @@ export const SystemTools = {
}, },
methods: { methods: {
shortCommit, shortCommit,
toPercent,
async loadBranches() { async loadBranches() {
try { try {
const data = await api.getUpdateBranches() const data = await api.getUpdateBranches()
@@ -72,15 +65,8 @@ export const SystemTools = {
this.branchLoading = false this.branchLoading = false
} }
}, },
async loadFastStatus({ throwOnError = false } = {}) { async loadFastStatus() {
try { try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null }
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 {
@@ -174,7 +160,6 @@ 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")
} }
@@ -183,7 +168,7 @@ export const SystemTools = {
if (this.busy) return if (this.busy) return
this.busy = "check" this.busy = "check"
try { try {
await this.loadFastStatus({ throwOnError: true }) await this.loadFastStatus()
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.")
@@ -242,7 +227,6 @@ 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")
} }
@@ -272,27 +256,14 @@ 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="updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span> <span v-else-if="fastStatus.updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else-if="checkedForUpdates" class="gx-chip">Up to date</span> <span v-else 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;">
@@ -316,7 +287,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 v-if="updateAvailable" type="button" class="gx-btn" :disabled="!!busy || isOnroad" @click="applyFastUpdate"> <button type="button" class="gx-btn" :disabled="!updateAvailable || !!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: "Bluetooth", link: "/bluetooth", icon: "bi-bluetooth", desc: "Pair devices, controllers, & audio" }, { name: "Cameras & Monitoring", link: "/cameras", icon: "bi-camera-video", desc: "PiP side camera & V-ASM spot monitor" },
{ 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 & vehicle features" }, { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, 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,4 +1,5 @@
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"
@@ -6,15 +7,16 @@ 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, Plots, TestingGround, GalaxyTabs }, components: { LateralTuningPanel, LongitudinalManeuvers, Plots, TestingGround, GalaxyTabs },
setup() { setup() {
return useTabRouting("/tuning", { lateral: "lateral", plots: "plots", testing: "testing" }) return useTabRouting("/tuning", { lateral: "lateral", long: "long", plots: "plots", testing: "testing" })
}, },
data() { return { TABS } }, data() { return { TABS } },
template: ` template: `
@@ -27,6 +29,10 @@ 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,6 +1,7 @@
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 { 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 { GalaxyTabs } from "../components/GalaxyTabs.js"
import { useTabRouting } from "../composables.js" import { useTabRouting } from "../composables.js"
@@ -12,12 +13,13 @@ const FEATURES = [
const TABS = { const TABS = {
controllers: "Controllers", controllers: "Controllers",
bluetooth: "Bluetooth",
features: "Vehicle Features", features: "Vehicle Features",
} }
export const Vehicle = { export const Vehicle = {
name: "Vehicle", name: "Vehicle",
components: { WheelControls, GalaxySection, GalaxyTabs }, components: { WheelControls, BluetoothPanel, GalaxySection, GalaxyTabs },
data() { data() {
return { return {
TABS, TABS,
@@ -27,7 +29,7 @@ export const Vehicle = {
} }
}, },
setup() { setup() {
return useTabRouting("/vehicle", { controllers: "controllers", features: "features" }) return useTabRouting("/vehicle", { controllers: "controllers", bluetooth: "bluetooth", features: "features" })
}, },
computed: { computed: {
featureList() { return this.features }, featureList() { return this.features },
@@ -69,6 +71,10 @@ export const Vehicle = {
<WheelControls /> <WheelControls />
</template> </template>
<template v-else-if="tab === 'bluetooth'">
<BluetoothPanel />
</template>
<template v-else> <template v-else>
<GalaxySection title="Vehicle Features" icon="bi-check2-square"> <GalaxySection title="Vehicle Features" icon="bi-check2-square">
<div style="padding: var(--sp-3); display:grid; gap:8px;"> <div style="padding: var(--sp-3); display:grid; gap:8px;">
@@ -1,6 +1,6 @@
{ {
"name": "Galaxy", "name": "Big Dipper",
"short_name": "Galaxy", "short_name": "Big Dipper",
"description": "Control and configure your openpilot device from anywhere.", "description": "Control and configure your openpilot device from anywhere.",
"icons": [ "icons": [
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
@@ -8,6 +8,7 @@
{ "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } { "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
], ],
"id": "46bf2df73deba8e1512c35de", "id": "46bf2df73deba8e1512c35de",
"start_url": "/mobile/",
"scope": "/", "scope": "/",
"background_color": "#06060f", "background_color": "#06060f",
"theme_color": "#8b6cc5", "theme_color": "#8b6cc5",
@@ -30,7 +30,7 @@
<link rel="stylesheet" href="/assets/components/tools/error_logs.css"> <link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/maps.css"> <link rel="stylesheet" href="/assets/components/tools/maps.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css"> <link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-5"> <link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-4">
<link rel="stylesheet" href="/assets/components/tools/plots.css"> <link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css"> <link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css"> <link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
@@ -42,7 +42,7 @@
<link rel="stylesheet" href="/assets/components/tools/update_manager.css"> <link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1"> <link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-6"> <link rel="stylesheet" href="/assets/components/tools/bluetooth.css?v=bluetooth-6">
<link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-3"> <link rel="stylesheet" href="/assets/components/tools/wheel_controls.css?v=controllers-2">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css"> <link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/sentry.css"> <link rel="stylesheet" href="/assets/components/tools/sentry.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css"> <link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
@@ -51,7 +51,7 @@
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css"> <link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module"> <script type="module">
import("/assets/components/router.js?v=router-cycle-fix-8").catch((err) => { import("/assets/components/router.js?v=router-cycle-fix-7").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err); console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body; const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre"); const pre = document.createElement("pre");
@@ -1859,16 +1859,9 @@ def test_model_profiles_can_be_selected_without_external_gpu(monkeypatch, tmp_pa
assert status["activeBigModel"] == "big-one" assert status["activeBigModel"] == "big-one"
assert status["activeSmallModel"] == "small-one" assert status["activeSmallModel"] == "small-one"
monkeypatch.setattr(server, "external_gpu_available", lambda: True)
active_big_response = client.put("/api/models/active", json={"profile": "big", "model": "big-one"})
assert active_big_response.status_code == 200
assert params.values["Model"] == params.values["DrivingModel"] == "big-one"
assert params.values["DrivingModelName"] == "Big One"
disabled = client.put("/api/models/active", json={"profile": "big", "model": ""}) disabled = client.put("/api/models/active", json={"profile": "big", "model": ""})
assert disabled.status_code == 200 assert disabled.status_code == 200
assert params.values["ActiveBigModel"] == "none" assert params.values["ActiveBigModel"] == "none"
assert params.values["Model"] == params.values["DrivingModel"] == "small-one"
assert disabled.get_json()["model"] == "" assert disabled.get_json()["model"] == ""
assert client.get("/api/models/status").get_json()["activeBigModel"] == "" assert client.get("/api/models/status").get_json()["activeBigModel"] == ""
@@ -1906,12 +1899,6 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
"ModelManifestVersion": "v25", "ModelManifestVersion": "v25",
"Model": "rdf43", "Model": "rdf43",
"DrivingModel": "rdf43", "DrivingModel": "rdf43",
"ActiveSmallModel": "rdf43",
"ActiveSmallModelName": "Regret Driven Framework V4",
"ActiveSmallModelVersion": "v15",
"ActiveBigModel": "big",
"ActiveBigModelName": "Chestnut One Billion",
"ActiveBigModelVersion": "v16",
}) })
metadata = { metadata = {
"lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, "lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True,
@@ -1987,16 +1974,10 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
queued = client.post("/api/model-laboratory/download", json={"model": "old"}) queued = client.post("/api/model-laboratory/download", json={"model": "old"})
assert queued.status_code == 200 assert queued.status_code == 200
assert params_memory.values["ModelLabModelToDownload"] == "old" assert params_memory.values["ModelLabModelToDownload"] == "old"
assert "eGPU variant" in params_memory.values["ModelDownloadProgress"] assert "precompiled AMD" in params_memory.values["ModelDownloadProgress"]
params_memory.remove("ModelLabModelToDownload") params_memory.remove("ModelLabModelToDownload")
monkeypatch.setattr(server, "external_gpu_available", lambda: False) monkeypatch.setattr(server, "external_gpu_available", lambda: False)
(tmp_path / "old_driving_chestnut_tinygrad.pkl").unlink(missing_ok=True)
queued_without_chestnut = client.post("/api/model-laboratory/download", json={"model": "old"})
assert queued_without_chestnut.status_code == 200
assert params_memory.values["ModelLabModelToDownload"] == "old"
params_memory.remove("ModelLabModelToDownload")
no_chestnut = client.put("/api/model-laboratory", json={ no_chestnut = client.put("/api/model-laboratory", json={
"enabled": True, "enabled": True,
"lateralModel": "lat", "lateralModel": "lat",
@@ -2005,21 +1986,6 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v
assert no_chestnut.status_code == 409 assert no_chestnut.status_code == 409
assert "Chestnut" in no_chestnut.get_json()["error"] assert "Chestnut" in no_chestnut.get_json()["error"]
monkeypatch.setattr(server, "external_gpu_available", lambda: True)
disabled = client.put("/api/model-laboratory", json={
"enabled": False,
"lateralModel": "lat",
"longitudinalModel": "long",
})
assert disabled.status_code == 200
assert params.values["Model"] == params.values["DrivingModel"] == "big"
assert params.values["DrivingModelName"] == "Chestnut One Billion"
deleted = client.delete("/api/model-laboratory/artifact", json={"model": "lat"})
assert deleted.status_code == 200
assert not (tmp_path / "lat_driving_chestnut_tinygrad.pkl").exists()
assert (tmp_path / "lat_driving_tinygrad.pkl").exists()
params.values["IsOnroad"] = True params.values["IsOnroad"] = True
onroad = client.put("/api/model-laboratory", json={"enabled": False}) onroad = client.put("/api/model-laboratory", json={"enabled": False})
assert onroad.status_code == 403 assert onroad.status_code == 403
@@ -46,23 +46,6 @@ def test_device_settings_uses_the_params_api_and_layout_json():
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
def test_device_settings_speed_units_follow_the_vehicle():
source = _device_settings()
assert 'from "/assets/mobile/js/params.js"' in source
assert "resolveVehicleUnitParam" in source
assert "formatNumericParamValue" in source
assert "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,15 +66,6 @@ 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 = {
@@ -116,50 +107,6 @@ def test_device_shutdown_uses_literal_hours():
assert device_shutdown["step"] == 1 assert device_shutdown["step"] == 1
def test_speed_settings_follow_vehicle_units_with_one_unit_steps():
sections = _params_by_section(_layout())
speed_keys = {
"MinimumLaneChangeSpeed", "PauseLateralSpeed",
"CESpeed", "CESpeedLead", "CESignalSpeed",
"CustomCruise", "CustomCruiseLong", "SetSpeedOffset", "PulseGlideSpeedDelta",
"Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7",
"CCMSpeed", "CCMSpeedLead", "CCMSetSpeedMargin",
"VisionSpeedLimitLowLimitThreshold", "TurnSteeringLimitMuteSpeed",
}
params = {
param["key"]: param
for section in sections.values()
for param in section.values()
if param["key"] in speed_keys
}
assert params.keys() == speed_keys
assert all(param["unit_type"] == "vehicle_speed" for param in params.values())
one_unit_keys = speed_keys - {"PulseGlideSpeedDelta", "VisionSpeedLimitLowLimitThreshold"}
assert all(params[key]["step"] == 1 for key in one_unit_keys)
assert params["PulseGlideSpeedDelta"]["step"] == 0.5
assert params["VisionSpeedLimitLowLimitThreshold"]["step"] == 5
for index in range(7):
offset = params[f"Offset{index + 1}"]
assert offset["unit_range_index"] == index
assert (offset["metric_min"], offset["metric_max"]) == (-150, 150)
assert params["CustomCruise"]["metric_max"] == 150
assert params["CCMSetSpeedMargin"]["metric_max"] == 30
assert params["PulseGlideSpeedDelta"]["imperial_max"] == 15
def test_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,7 +7,6 @@ ROUTER_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/router.
INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html" INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html"
BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js" BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js"
CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js" CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js"
MOBILE_CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js"
SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js" SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js"
MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js" MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js"
@@ -24,7 +23,7 @@ def test_router_and_settings_cache_bust_is_consistent():
index = INDEX_PATH.read_text(encoding="utf-8") index = INDEX_PATH.read_text(encoding="utf-8")
assert "/assets/components/settings.js?v=router-cycle-fix-5" in router assert "/assets/components/settings.js?v=router-cycle-fix-5" in router
assert "/assets/components/router.js?v=router-cycle-fix-8" in index assert "/assets/components/router.js?v=router-cycle-fix-7" in index
def test_bluetooth_actions_use_reactive_disabled_bindings(): def test_bluetooth_actions_use_reactive_disabled_bindings():
@@ -77,16 +76,6 @@ def test_controller_joystick_mode_requires_explicit_device_selection():
assert 'request("joystick", { device_id: device.device_id, enabled: !selected() })' in source assert 'request("joystick", { device_id: device.device_id, enabled: !selected() })' in source
def test_controller_offroad_disconnect_is_opt_in():
source = CONTROLLERS_PATH.read_text(encoding="utf-8")
mobile_source = MOBILE_CONTROLLERS_PATH.read_text(encoding="utf-8")
for frontend in (source, mobile_source):
assert "Disconnect controllers when offroad" in frontend
assert "After two minutes offroad" in frontend
assert "offroad-disconnect" in frontend
def test_controller_page_has_ten_controller_only_action_slots(): def test_controller_page_has_ten_controller_only_action_slots():
source = CONTROLLERS_PATH.read_text(encoding="utf-8") source = CONTROLLERS_PATH.read_text(encoding="utf-8")
@@ -128,13 +117,8 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy():
assert 'if (!state.chestnutReady)' in source assert 'if (!state.chestnutReady)' in source
assert 'if (state.isOnroad)' in source assert 'if (state.isOnroad)' in source
assert "model.modelLabArtifactInstalled" in source assert "model.modelLabArtifactInstalled" in source
assert "Download eGPU-compatible small models" in source assert "Nothing is compiled on the comma" in source
assert "Choose from downloaded eGPU variant combinations below" in source assert "run every camera frame on Chestnut's AMD GPU" in source
assert "Download eGPU variant" in source
assert "Delete eGPU variant" in source
assert "Nothing is compiled on the comma" not in source
assert "availableModels().filter(model => model.modelLabArtifactInstalled)" in source
assert source.index("<h3>Available models</h3>") < source.index("<h3>Compose a pair</h3>")
assert 'lateral.value === longitudinal.value' in source assert 'lateral.value === longitudinal.value' in source
assert 'lateral.version !== longitudinal.version' not in source assert 'lateral.version !== longitudinal.version' not in source
assert 'class="ml-chip ${' not in source assert 'class="ml-chip ${' not in source
@@ -151,5 +135,5 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy():
assert "longitudinalModel: selectionDirty" in source assert "longitudinalModel: selectionDirty" in source
assert source.count("selectionDirty = true") == 2 assert source.count("selectionDirty = true") == 2
assert "selectionDirty = false\n applyPayload(payload)" in source assert "selectionDirty = false\n applyPayload(payload)" in source
assert 'model_laboratory.js?v=model-lab-6' in ROUTER_PATH.read_text(encoding="utf-8") assert 'model_laboratory.js?v=model-lab-5' in ROUTER_PATH.read_text(encoding="utf-8")
assert 'model_laboratory.css?v=model-lab-5' in INDEX_PATH.read_text(encoding="utf-8") assert 'model_laboratory.css?v=model-lab-4' in INDEX_PATH.read_text(encoding="utf-8")
@@ -231,25 +231,6 @@ def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
"__starpilot_controller_action__:disengage_openpilot", "__starpilot_controller_action__:disengage_openpilot",
} }
assert response.get_json()["speed_unit"] == "mph" assert response.get_json()["speed_unit"] == "mph"
assert response.get_json()["disconnect_controllers_offroad"] is False
def test_wheel_controls_configures_offroad_controller_disconnect(monkeypatch):
client, fake_params = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
response = client.post("/api/wheel-controls/offroad-disconnect", json={"enabled": True})
assert response.status_code == 200
assert fake_params.get_bool("BluetoothDisconnectControllersOffroad")
def test_wheel_controls_offroad_controller_disconnect_requires_offroad(monkeypatch):
client, fake_params = _params_client(monkeypatch, {"IsOffroad": False}, "mici")
response = client.post("/api/wheel-controls/offroad-disconnect", json={"enabled": True})
assert response.status_code == 409
assert not fake_params.get_bool("BluetoothDisconnectControllersOffroad")
def test_wheel_controls_configures_a_controller_only_action(monkeypatch): def test_wheel_controls_configures_a_controller_only_action(monkeypatch):
@@ -62,26 +62,6 @@ def test_slug_middleware_service_worker_and_headers(client):
assert response_direct.headers.get("Service-Worker-Allowed") == "/" assert response_direct.headers.get("Service-Worker-Allowed") == "/"
def test_mobile_manifest_embeds_device_slug_for_fresh_app_login(client):
galaxy_dir = the_galaxy._get_galaxy_dir()
galaxy_dir.mkdir(parents=True, exist_ok=True)
(galaxy_dir / "glxyslug").write_text("df70390ca648d7c3")
response = client.get("/assets/mobile/manifest.json")
assert response.status_code == 200
assert response.mimetype == "application/manifest+json"
assert response.get_json()["start_url"] == "https://galaxy.firestar.link/df70390ca648d7c3"
assert "no-store" in response.headers.get("Cache-Control", "")
def test_mobile_manifest_falls_back_to_local_mobile_route_without_slug(client):
response = client.get("/assets/mobile/manifest.json")
assert response.status_code == 200
assert response.get_json()["start_url"] == "/mobile/"
def test_404_api_returns_json_not_html(client): def test_404_api_returns_json_not_html(client):
# Non-existent API route without slug # Non-existent API route without slug
res1 = client.get("/api/nonexistent") res1 = client.get("/api/nonexistent")
@@ -44,7 +44,6 @@ 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:
@@ -56,8 +55,6 @@ 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():
@@ -110,7 +107,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"], "js/views/Tuning.js": ["LateralTuningPanel", "LongitudinalManeuvers"],
"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": [
@@ -125,9 +122,7 @@ 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")
bluetooth = _read("js/views/Bluetooth.js") assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle and "carFeaturesCheck" in vehicle
assert "WheelControls" in vehicle and "BluetoothPanel" not in vehicle and "carFeaturesCheck" in vehicle
assert "BluetoothPanel" in bluetooth
def test_ui_routes_ported_views_natively_no_classic_fallback(): def test_ui_routes_ported_views_natively_no_classic_fallback():
@@ -135,16 +130,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", "Bluetooth", "SystemTools"]: for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "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", "/bluetooth", "/system"]: for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/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", "/bluetooth", "/system"]: for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/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
@@ -195,23 +190,6 @@ def test_ui_numeric_toggles_are_sliders_with_default():
assert 'title="Set to zero"' not in card assert 'title="Set to zero"' not in card
def test_ui_speed_units_follow_the_vehicle():
params = _read("js/params.js")
card = _read("js/components/GalaxyToggleCard.js")
tree = _read("js/components/SettingTree.js")
settings = _read("js/views/Settings.js")
assert "resolveVehicleUnitParam" in params
assert "formatNumericParamValue" in params
assert "unit_search_terms" in params and "unit_search_terms" in settings
assert "IsMetric" in params
assert ':values="values"' in tree
assert "displayParam" in card and "formatNumericParamValue" in card
assert "sliderStepDisplay" in card and "Step:" in card
assert ':values="values"' in settings
assert "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")
@@ -231,10 +209,10 @@ 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" not in tuning assert "LateralTuningPanel" in tuning and "LongitudinalManeuvers" in tuning
vehicle = _read("js/views/Vehicle.js") vehicle = _read("js/views/Vehicle.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" not in vehicle assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle
assert "GalaxySection" in vehicle assert "GalaxySection" in vehicle
engine = _read("js/components/ParamSections.js") engine = _read("js/components/ParamSections.js")
assert "SettingTree" in engine assert "SettingTree" in engine
@@ -290,7 +268,6 @@ 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():
@@ -348,6 +325,8 @@ 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
@@ -362,10 +341,9 @@ 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"] == "Galaxy" assert manifest["name"]
assert manifest["short_name"] == "Galaxy"
assert manifest["icons"] assert manifest["icons"]
assert "start_url" not in manifest assert manifest["start_url"] == "/mobile/"
def test_ui_ported_classic_tools_native_no_embed(): def test_ui_ported_classic_tools_native_no_embed():
@@ -422,6 +400,7 @@ 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",
@@ -435,10 +414,6 @@ 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
@@ -474,9 +449,8 @@ 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 "Sentry" in cameras and "Vasm" in cameras and "Pip" in cameras, "camera hub should embed Sentry, V-ASM, and PiP" assert "Vasm" in cameras and "Pip" in cameras, "camera hub should embed 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"]:
@@ -496,40 +470,6 @@ 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
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"),
@@ -565,23 +505,6 @@ assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 } const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap") assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format") assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
const speed = {
key: "Offset2", data_type: "float", unit_type: "vehicle_speed",
min: -99, max: 99, step: 1, precision: 0,
metric_min: -150, metric_max: 150, unit_range_index: 1,
}
const imperialSpeed = P.resolveVehicleUnitParam(speed, { IsMetric: false })
assert(imperialSpeed.unit === " mph", "imperial unit")
assert(imperialSpeed.label === "Speed Offset (2534 mph)", "imperial offset band")
assert(P.formatNumericParamValue(speed, 3, { IsMetric: false }) === "3 mph", "imperial value")
const metricSpeed = P.resolveVehicleUnitParam(speed, { IsMetric: true })
assert(metricSpeed.unit === " km/h", "metric unit")
assert(metricSpeed.label === "Speed Offset (3049 km/h)", "metric offset band")
assert(metricSpeed.unit_search_terms.includes("metric"), "metric settings are searchable")
assert(P.numericBounds(speed, { IsMetric: true }).max === 150, "metric bounds")
assert(P.numericBounds(speed, { IsMetric: true }).step === 1, "one km/h per step")
assert(P.formatNumericParamValue(speed, 3, { IsMetric: true }) === "3 km/h", "metric value")
assert(P.usesMetricUnits({ IsMetric: "1" }) === true, "serialized metric bool")
const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 } const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 }
const laneBounds = P.numericBounds(laneOffset, {}) const laneBounds = P.numericBounds(laneOffset, {})
assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound") assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound")
+22 -86
View File
@@ -5015,7 +5015,6 @@ def setup(app):
"/assets/components/settings.js", "/assets/components/settings.js",
"/assets/components/home/home.js", "/assets/components/home/home.js",
"/assets/components/home/home.css", "/assets/components/home/home.css",
"/assets/mobile/js/params.js",
"/assets/components/tools/device_settings.js", "/assets/components/tools/device_settings.js",
"/assets/components/tools/device_settings.css", "/assets/components/tools/device_settings.css",
"/assets/components/tools/device_settings_layout.json", "/assets/components/tools/device_settings_layout.json",
@@ -5182,7 +5181,6 @@ def setup(app):
status["slots"] = slots status["slots"] = slots
status["controller_slots"] = controller_slots status["controller_slots"] = controller_slots
status["controller_options"] = controller_options status["controller_options"] = controller_options
status["disconnect_controllers_offroad"] = params.get_bool("BluetoothDisconnectControllersOffroad")
is_metric = params.get_bool("IsMetric") is_metric = params.get_bool("IsMetric")
speed_minimum, speed_maximum = controller_speed_bounds(is_metric) speed_minimum, speed_maximum = controller_speed_bounds(is_metric)
status["speed_unit"] = "km/h" if is_metric else "mph" status["speed_unit"] = "km/h" if is_metric else "mph"
@@ -5192,16 +5190,13 @@ def setup(app):
@app.route("/api/wheel-controls/<operation>", methods=["POST"]) @app.route("/api/wheel-controls/<operation>", methods=["POST"])
def wheel_controls_operation(operation): def wheel_controls_operation(operation):
if operation not in {"action", "learn", "cancel", "delete", "clear", "test", "test-stop", "joystick", "offroad-disconnect"}: if operation not in {"action", "learn", "cancel", "delete", "clear", "test", "test-stop", "joystick"}:
return jsonify({"error": "Unknown wheel control operation."}), 404 return jsonify({"error": "Unknown wheel control operation."}), 404
if not params.get_bool("IsOffroad"): if not params.get_bool("IsOffroad"):
return jsonify({"error": "Wheel controls can only be configured offroad."}), 409 return jsonify({"error": "Wheel controls can only be configured offroad."}), 409
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
try: try:
if operation == "offroad-disconnect":
params.put_bool("BluetoothDisconnectControllersOffroad", bool(data.get("enabled", False)))
return jsonify({"message": "Offroad controller disconnect updated."}), 200
if operation == "action": if operation == "action":
slot_index = int(data.get("slot", -1)) slot_index = int(data.get("slot", -1))
key = str(data.get("key") or "").strip() key = str(data.get("key") or "").strip()
@@ -5301,27 +5296,6 @@ def setup(app):
return "Settings catalog not found", 404 return "Settings catalog not found", 404
return send_file(str(SETTINGS_CATALOG_PATH), mimetype="application/json") return send_file(str(SETTINGS_CATALOG_PATH), mimetype="application/json")
@app.route("/assets/mobile/manifest.json", methods=["GET"])
def mobile_manifest():
manifest_path = Path(app.static_folder) / "mobile" / "manifest.json"
if not manifest_path.is_file():
return jsonify({"error": "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():
@@ -6348,24 +6322,21 @@ def setup(app):
}, },
"manifest": { "manifest": {
"version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown", "version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown",
"shortcomings": [
"The current manifest does not consistently declare model size; legacy non-Chestnut entries are treated as small.",
"The current manifest does not publish AMD-compiled variants for its ordinary small-model downloads.",
"The current manifest does not declare lateral or longitudinal quality/capability tags.",
"The current manifest does not declare output-contract compatibility, memory, or frame-time measurements.",
],
"opportunities": [
"Publish model_size and model_lab_eligible for every model.",
"Publish an accelerator_artifacts.chestnut entry pointing to a precompiled AMD pickle for each supported small model.",
"Publish role scores and pairing notes from replay evaluations.",
"Publish architecture, output-contract, peak-memory, and p50/p95 execution metadata.",
],
}, },
} }
def _activate_preferred_model_profile():
"""Restore the model that the normal small/big profile system would run."""
profile = "big" if external_gpu_available() and _active_model_key("big") else "small"
model_key, model_name, model_version = get_model_profile(params, profile)
if not model_key:
model_key, model_name, model_version = _default_model_key(), _default_model_name(), _default_model_version()
params.put("Model", model_key)
params.put("DrivingModel", model_key)
params.put("DrivingModelName", model_name or model_key)
if model_version:
params.put("ModelVersion", model_version)
params.put("DrivingModelVersion", model_version)
return model_name or model_key
@app.route("/api/model-laboratory", methods=["GET", "PUT"]) @app.route("/api/model-laboratory", methods=["GET", "PUT"])
def model_laboratory(): def model_laboratory():
if request.method == "GET": if request.method == "GET":
@@ -6404,8 +6375,7 @@ def setup(app):
params.put("DrivingModelVersion", lateral["version"]) params.put("DrivingModelVersion", lateral["version"])
message = "Model Laboratory enabled. The pair will load on the next drive." message = "Model Laboratory enabled. The pair will load on the next drive."
else: else:
restored_model = _activate_preferred_model_profile() message = "Model Laboratory disabled."
message = f"Model Laboratory disabled. {restored_model} will be used next."
return jsonify({"message": message, **_model_lab_status_payload()}), 200 return jsonify({"message": message, **_model_lab_status_payload()}), 200
@@ -6413,6 +6383,8 @@ def setup(app):
def download_model_laboratory_artifact(): def download_model_laboratory_artifact():
if params.get_bool("IsOnroad"): if params.get_bool("IsOnroad"):
return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403 return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403
if not external_gpu_available():
return jsonify({"error": "Chestnut is not connected and firmware-ready."}), 409
if ( if (
params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
@@ -6426,50 +6398,16 @@ def setup(app):
if model is None: if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404 return jsonify({"error": f"Unknown model '{model_key}'."}), 404
if not model.get("modelLabEligible"): if not model.get("modelLabEligible"):
return jsonify({"error": "Only compatible small models have Model Laboratory eGPU variants."}), 409 return jsonify({"error": "Only compatible small models can be prepared for Model Laboratory."}), 409
if not model.get("modelLabArtifactAvailable"): if not model.get("modelLabArtifactAvailable"):
return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409 return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409
if model.get("modelLabArtifactInstalled"): if model.get("modelLabArtifactInstalled"):
return jsonify({"message": f"The eGPU variant for \"{model['label']}\" is already downloaded."}), 200 return jsonify({"message": f"\"{model['label']}\" is already prepared for Chestnut."}), 200
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key) params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key)
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Starting eGPU variant download...") params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading precompiled AMD artifact...")
return jsonify({"message": f"Started downloading the eGPU variant for \"{model['label']}\"."}), 200 return jsonify({"message": f"Started preparing \"{model['label']}\" for Chestnut."}), 200
@app.route("/api/model-laboratory/artifact", methods=["DELETE"])
def delete_model_laboratory_artifact():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Model Laboratory eGPU variants can only be deleted while parked."}), 403
if (
params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "")
):
return jsonify({"error": "Cannot delete an eGPU variant while a model download is in progress."}), 409
data = request.get_json(silent=True) or {}
model_key = canonical_model_key(str(data.get("model") or "").strip())
model = next((entry for entry in get_model_catalog() if entry["value"] == model_key), None)
if model is None:
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
if not model.get("modelLabArtifactInstalled"):
return jsonify({"message": f"No eGPU variant is downloaded for \"{model['label']}\"."}), 200
config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "")
if config["enabled"] and model_key in (config["lateralModel"], config["longitudinalModel"]):
return jsonify({"error": "Disable Model Laboratory or choose a different pair before deleting this eGPU variant."}), 409
artifact_path = MODELS_PATH / model_accelerator_artifact_filename(model_key)
try:
artifact_path.unlink(missing_ok=True)
Path(get_manifest_path(artifact_path)).unlink(missing_ok=True)
for chunk_path in artifact_path.parent.glob(f"{artifact_path.name}.chunk*of*"):
chunk_path.unlink(missing_ok=True)
except Exception as exception:
return jsonify({"error": f"Failed deleting the eGPU variant: {exception}"}), 500
return jsonify({"message": f"Deleted the eGPU variant for \"{model['label']}\".", **_model_lab_status_payload()}), 200
@app.route("/api/models/preferences", methods=["GET", "PUT"]) @app.route("/api/models/preferences", methods=["GET", "PUT"])
def get_or_set_models_preferences(): def get_or_set_models_preferences():
@@ -6523,9 +6461,8 @@ def setup(app):
params.remove(MODEL_LAB_RUNTIME_PARAM) params.remove(MODEL_LAB_RUNTIME_PARAM)
disable_big_model_profile(params) disable_big_model_profile(params)
restored_model = _activate_preferred_model_profile()
return jsonify({ return jsonify({
"message": f"Active Big disabled. {restored_model} will be used even when Chestnut is connected.", "message": "Active Big disabled. Active Small will be used even when Chestnut is connected.",
"profile": profile, "profile": profile,
"model": "", "model": "",
}), 200 }), 200
@@ -6547,9 +6484,8 @@ def setup(app):
params.remove(MODEL_LAB_RUNTIME_PARAM) params.remove(MODEL_LAB_RUNTIME_PARAM)
set_model_profile(params, profile, model_key, model["label"], model["version"]) set_model_profile(params, profile, model_key, model["label"], model["version"])
active_model = _activate_preferred_model_profile()
return jsonify({ return jsonify({
"message": f"Active {profile.title()} set to '{model['label']}'. {active_model} will be used next.", "message": f"Active {profile.title()} set to '{model['label']}'.",
"profile": profile, "profile": profile,
"model": model_key, "model": model_key,
}), 200 }), 200
+9 -30
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"
GALAXY_NEW_DEFAULT_KEY = "GalaxyMobileDefault" REVERSE_CRUISE_KEY = "ReverseCruise"
DEFAULT_STEER_KP = 0.6 DEFAULT_STEER_KP = 0.6
LEGACY_STEER_KP = 0.7 LEGACY_STEER_KP = 0.7
@@ -39,8 +39,7 @@ 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_RESTORE_MIGRATION_MARKER = ".starpilot_restore_reverse_cruise_v1" REVERSE_CRUISE_REMOVAL_MIGRATION_MARKER = ".starpilot_remove_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 = (
@@ -148,12 +147,8 @@ 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_restore_marker_path(params: ParamsLike) -> Path: def _reverse_cruise_removal_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / REVERSE_CRUISE_RESTORE_MIGRATION_MARKER return _marker_dir_path(params) / REVERSE_CRUISE_REMOVAL_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:
@@ -337,24 +332,12 @@ def _apply_camera_view_default_migration(params: ParamsLike, marker: Path) -> No
marker.touch() marker.touch()
def _restore_reverse_cruise_param(params: ParamsLike, marker: Path) -> None: def _remove_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)
if (not _param_file_exists(params, "ReverseCruise") and Path(params.get_param_path(REVERSE_CRUISE_KEY)).unlink(missing_ok=True)
_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()
@@ -369,8 +352,7 @@ 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_restore_marker_path: Path | None = None, reverse_cruise_removal_marker_path: Path | None = 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.
@@ -402,11 +384,8 @@ 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)
) )
_restore_reverse_cruise_param( _remove_reverse_cruise_param(
params, reverse_cruise_restore_marker_path or _reverse_cruise_restore_marker_path(params) params, reverse_cruise_removal_marker_path or _reverse_cruise_removal_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", "CoastUpToLeads", "HumanAcceleration", "HumanFollowing", "PrioritizeSmoothFollowing", "ReverseCruise",
) )
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",
+11 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import os import os
import operator import operator
import platform import platform
@@ -6,6 +8,7 @@ import sys
from types import SimpleNamespace from types import SimpleNamespace
from cereal import car from cereal import car
from openpilot.common.gps import gm_car_params_present
from openpilot.common.params import Params from openpilot.common.params import Params
from opendbc.car.gps import car_gps_available from opendbc.car.gps import car_gps_available
from openpilot.system.hardware import HARDWARE, PC, TICI from openpilot.system.hardware import HARDWARE, PC, TICI
@@ -36,9 +39,12 @@ def update_car_gps_param(params: Params) -> bool | None:
car_params = params.get("CarParams") car_params = params.get("CarParams")
if car_params is None: if car_params is None:
return None return None
try:
with car.CarParams.from_bytes(car_params) as parsed_cp:
available = car_gps_available(parsed_cp)
except Exception:
return None
with car.CarParams.from_bytes(car_params) as CP:
available = car_gps_available(CP)
if available != params.get_bool("CarGpsAvailable"): if available != params.get_bool("CarGpsAvailable"):
params.put_bool("CarGpsAvailable", available) params.put_bool("CarGpsAvailable", available)
return available return available
@@ -48,7 +54,9 @@ def ublox(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: S
use_ublox = ublox_available() use_ublox = ublox_available()
if use_ublox != params.get_bool("UbloxAvailable"): if use_ublox != params.get_bool("UbloxAvailable"):
params.put_bool("UbloxAvailable", use_ublox) params.put_bool("UbloxAvailable", use_ublox)
return started and use_ublox and car_gps is False is_gm = gm_car_params_present(params)
# On GM, card arbitrates u-blox and CAN GPS; wait for CarParams.
return started and use_ublox and car_gps is not None and (not car_gps or is_gm)
def joystick(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: def joystick(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return started and params.get_bool("JoystickDebugMode") return started and params.get_bool("JoystickDebugMode")
@@ -7,7 +7,6 @@ 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,
@@ -15,7 +14,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_RESTORE_MIGRATION_MARKER, REVERSE_CRUISE_REMOVAL_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,
@@ -179,42 +178,14 @@ 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_restores_reverse_cruise_from_swapped_intervals(tmp_path): def test_apply_launch_param_migrations_removes_reverse_cruise_param(tmp_path):
params = FileBackedFakeParams(tmp_path / "params") params = FileBackedFakeParams(tmp_path / "params")
params.put_float("CustomCruise", 5.0) params.put_bool("ReverseCruise", True)
params.put_float("CustomCruiseLong", 1.0)
apply_launch_param_migrations(params) apply_launch_param_migrations(params)
assert params.get_bool("ReverseCruise") assert not Path(params.get_param_path("ReverseCruise")).exists()
assert marker_path(tmp_path, REVERSE_CRUISE_RESTORE_MIGRATION_MARKER).is_file() assert marker_path(tmp_path, REVERSE_CRUISE_REMOVAL_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):
+3 -1
View File
@@ -397,6 +397,7 @@ 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)
@@ -404,9 +405,10 @@ 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 params.get_bool("ReverseCruise") assert not Path(params.get_param_path("ReverseCruise")).exists()
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")
+28 -19
View File
@@ -4,6 +4,7 @@ import pytest
from cereal import car from cereal import car
from opendbc.car.ford.values import CAR as FORD_CAR from opendbc.car.ford.values import CAR as FORD_CAR
from opendbc.car.gm.values import CAR as GM_CAR
import openpilot.system.manager.process_config as process_config import openpilot.system.manager.process_config as process_config
from openpilot.system.manager.process_config import ( from openpilot.system.manager.process_config import (
allow_uploads, allow_uploads,
@@ -152,25 +153,33 @@ class GpsParams:
self.values[key] = value self.values[key] = value
def test_ublox_waits_for_current_carparams(monkeypatch): @pytest.mark.parametrize("brand,fingerprint,persisted,live_brand,expected,car_gps", [
monkeypatch.setattr("openpilot.system.manager.process_config.ublox_available", lambda: True) ("gm", "", False, "gm", False, False),
params = GpsParams() ("mock", "", False, "mock", False, False),
("ford", "", False, "ford", False, False),
("gm", GM_CAR.CHEVROLET_BOLT_CC_2018_2021, False, "gm", False, False),
("gm", GM_CAR.CHEVROLET_BOLT_CC_2018_2021, True, "gm", True, True),
("gm", GM_CAR.CHEVROLET_BOLT_CC_2018_2021, True, "", True, True),
("gm", "unknown GM", True, "gm", True, False),
("mock", "mock", True, "", True, False),
("ford", FORD_CAR.FORD_MUSTANG_MACH_E_MK1, True, "", False, True),
("gm", "", "corrupt", "", False, False),
], ids=["missing-gm", "missing-mock", "missing-ford", "stale-live-cp", "persisted-gm",
"empty-live-cp", "unknown-gm", "non-car-gps", "vehicle-gps", "corrupt-cp"])
def test_ublox_startup(monkeypatch, brand, fingerprint, persisted, live_brand, expected, car_gps):
monkeypatch.setattr(process_config, "ublox_available", lambda: True)
current = car.CarParams.new_message(brand=brand, carFingerprint=fingerprint)
params = GpsParams(current if persisted is True else None)
if persisted == "corrupt":
params.values["CarParams"] = b"invalid_corrupt_data"
live_cp = car.CarParams.new_message(brand=live_brand, carFingerprint=fingerprint if live_brand else "")
assert not ublox(True, params, car.CarParams.new_message(), SimpleNamespace()) assert ublox(True, params, live_cp, SimpleNamespace()) is expected
assert params.get_bool("UbloxAvailable") assert params.get_bool("UbloxAvailable")
@pytest.mark.parametrize("car_gps,expected", [(False, True), (True, False)])
def test_ublox_has_single_external_gps_publisher(monkeypatch, car_gps, expected):
monkeypatch.setattr("openpilot.system.manager.process_config.ublox_available", lambda: True)
CP = car.CarParams.new_message()
if car_gps:
CP.brand = "ford"
CP.carFingerprint = FORD_CAR.FORD_MUSTANG_MACH_E_MK1
else:
CP.brand = "mock"
CP.carFingerprint = "mock"
params = GpsParams(CP)
assert ublox(True, params, car.CarParams.new_message(), SimpleNamespace()) is expected
assert params.get_bool("CarGpsAvailable") is car_gps assert params.get_bool("CarGpsAvailable") is car_gps
assert not ublox(False, params, live_cp, SimpleNamespace())
if persisted is False and fingerprint:
params.values["CarParams"] = current.to_bytes()
assert ublox(True, params, live_cp, SimpleNamespace()) # Same stale live CP, now with current persisted CP.
monkeypatch.setattr(process_config, "ublox_available", lambda: False)
assert not ublox(True, params, live_cp, SimpleNamespace())
+3 -1
View File
@@ -63,7 +63,8 @@ def main() -> NoReturn:
gps_location_service = get_gps_location_service(params) gps_location_service = get_gps_location_service(params)
pm = messaging.PubMaster(['clocks']) pm = messaging.PubMaster(['clocks'])
sm = messaging.SubMaster([gps_location_service]) # Subscribe to both services to support dynamic GM GPS routing.
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal'])
# StarPilot variables # StarPilot variables
tf = TimezoneFinder() if TimezoneFinder is not None else None tf = TimezoneFinder() if TimezoneFinder is not None else None
@@ -75,6 +76,7 @@ def main() -> NoReturn:
while True: while True:
sm.update(1000) sm.update(1000)
gps_location_service = get_gps_location_service(params)
msg = messaging.new_message('clocks') msg = messaging.new_message('clocks')
msg.valid = system_time_valid() msg.valid = system_time_valid()
+30
View File
@@ -0,0 +1,30 @@
from types import SimpleNamespace
import pytest
from cereal import car, messaging
from openpilot.system.ubloxd import ubloxd
@pytest.mark.parametrize("brand,service", [("gm", "gpsLocation"), ("mock", "gpsLocationExternal")])
def test_main_routes_every_parsed_fix(monkeypatch, brand, service):
cp = car.CarParams.new_message(brand=brand)
monkeypatch.setattr(ubloxd, "Params", lambda: SimpleNamespace(get=lambda key: cp.to_bytes()))
sent = []
pm = SimpleNamespace(send=lambda service, message: sent.append((service, message)))
monkeypatch.setattr(messaging, "PubMaster", lambda services: pm)
monkeypatch.setattr(messaging, "sub_sock", lambda *args, **kwargs: None)
# Several fixes in one receive cycle must all reach the publisher.
incoming = iter([SimpleNamespace(ubloxRaw=b"", logMonoTime=1)])
monkeypatch.setattr(messaging, "recv_one", lambda sock: next(incoming))
parser = ubloxd.UbloxMsgParser(service)
fixes = [messaging.new_message(service, valid=True) for _ in range(5)]
monkeypatch.setattr(parser.framer, "add_data", lambda *args: range(5))
monkeypatch.setattr(parser, "parse_frame", lambda index: (parser.gps_service, fixes[index]))
def make_parser(actual_service):
assert actual_service == service
return parser
monkeypatch.setattr(ubloxd, "UbloxMsgParser", make_parser)
with pytest.raises(StopIteration):
ubloxd.main()
assert sent == [(service, fix) for fix in fixes]

Some files were not shown because too many files have changed in this diff Show More