Compare commits

..

12 Commits

Author SHA1 Message Date
firestarsdog 352b23ddf5 Not quite a pollo bowl 2026-09-03 01:09:51 -04:00
Prabhaav Pillai c7a3e3297a cookie to authenticate mobile 2026-09-03 00:16:13 -04:00
Prabhaav Pillai b390513ad1 Add command to launch local Galaxy web UI in host_tool_runner.sh 2026-09-02 23:59:25 -04:00
Prabhaav Pillai 6f5e267493 Mobile Friendly Galaxy 2026-09-02 23:40:08 -04:00
firestar5683 bb3b1429eb I thought you said weast 2026-09-02 22:07:29 -05:00
firestar5683 3b4a570564 build 2026-09-02 20:06:05 -05:00
firestar5683 a3bdcf2417 nope 2026-09-02 20:05:29 -05:00
firestar5683 f553b8071d subuwu 2026-09-02 17:37:45 -05:00
firestar5683 f7fad2a4d5 build 2026-09-02 17:31:48 -05:00
firestar5683 5fe8b17467 weh 2026-09-02 17:31:24 -05:00
firestar5683 51d8062c36 hi 2026-09-02 13:56:15 -05:00
firestar5683 5122b8df42 h 2026-09-02 11:54:07 -05:00
411 changed files with 29948 additions and 14741 deletions
+5 -19
View File
@@ -159,21 +159,7 @@ All four files must be updated together.
## Manifest ## Manifest
The current test branch uses manifest v25 and requests v25 only. Seed the new Generate the base manifest after compilation, then namespace the release artifacts as v23:
manifest from the previous catalog, then replace entries as artifacts are
rebuilt with the pinned runtime:
```bash
cp /path/to/model_names_v24.json /path/to/model_names_v25.json
```
The current tinygrad pin is `f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae`, from
`openpilot` `origin/master` (`bump tg + TC_MIN_GLOBALS`). StarPilot's
multi-model `modeld` remains in place; do not replace it with upstream's
single-model `modeld`.
For the older namespace migration workflow, generate the base manifest after
compilation and namespace the release artifacts as v23:
```bash ```bash
python3 scripts/model_rebuild_pipeline.py manifest \ python3 scripts/model_rebuild_pipeline.py manifest \
@@ -189,9 +175,9 @@ python3 scripts/namespace_model_artifacts.py \
The namespace command changes IDs such as `tr1422` to `tr14223`, renames the The namespace command changes IDs such as `tr1422` to `tr14223`, renames the
compiled and upload-ready files, and writes an ID map. It preserves display compiled and upload-ready files, and writes an ID map. It preserves display
names and behavioral versions. The current model manager requests v25 only; the names and behavioral versions. The current model manager requests v23 only;
manifest is fetched from `Models/model_names_v25.json`. Devices still running the manifest is fetched from `Models/model_names_v23.json`, while v22 remains
the prior branch continue to request their existing manifest version. available for devices that have not updated yet.
After importing newly compiled sources, normalize the release namespace before After importing newly compiled sources, normalize the release namespace before
copying files into either resource repository: copying files into either resource repository:
@@ -219,4 +205,4 @@ Compilation validates JIT capture/replay, pickle round-trip, finite outputs, met
4. Confirm `driverStateV2` on both supported camera resolutions. 4. Confirm `driverStateV2` on both supported camera resolutions.
5. Test download, selection, deletion, randomization, migration, and fallback in both device UIs and Galaxy. 5. Test download, selection, deletion, randomization, migration, and fallback in both device UIs and Galaxy.
The built-in RDF artifact is `selfdrive/modeld/models/driving_tinygrad.pkl`. If migration cannot download the selected v25 artifact, StarPilot switches to that built-in model. The built-in RDF artifact is `selfdrive/modeld/models/driving_tinygrad.pkl`. If migration cannot download the selected v23 artifact, StarPilot switches to that built-in model.
+1 -1
View File
@@ -21,7 +21,7 @@ fi
export QCOM_PRIORITY=12 export QCOM_PRIORITY=12
if [ -z "$AGNOS_VERSION" ]; then if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="19.6.16" export AGNOS_VERSION="19.6.17"
fi fi
if [ -z "$AGNOS_ACCEPTED_VERSIONS" ]; then if [ -z "$AGNOS_ACCEPTED_VERSIONS" ]; then
@@ -8,7 +8,7 @@ from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_steer_an
from opendbc.car.common.conversions import Conversions as CV from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.hyundai import hyundaicanfd, hyundaican from opendbc.car.hyundai import hyundaicanfd, hyundaican
from opendbc.car.hyundai.hyundaicanfd import CanBus from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags, Buttons, CarControllerParams, CAR, CANFD_ANGLE_LONGITUDINAL_CAR, \ from opendbc.car.hyundai.values import HyundaiFlags, HyundaiStarPilotFlags, Buttons, CarControllerParams, CAR, CANFD_ANGLE_LONGITUDINAL_CAR, \
CANFD_RADAR_LIVE_LONGITUDINAL_CAR, CANFD_ALT_BUTTONS_RESUME_CAR, kia_ev6_gt_line_longitudinal_tuning, \ CANFD_RADAR_LIVE_LONGITUDINAL_CAR, CANFD_ALT_BUTTONS_RESUME_CAR, kia_ev6_gt_line_longitudinal_tuning, \
KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID
from opendbc.car.interfaces import CarControllerBase from opendbc.car.interfaces import CarControllerBase
@@ -777,6 +777,8 @@ class CarController(CarControllerBase):
left_lane_warning, right_lane_warning, lka_icon)) left_lane_warning, right_lane_warning, lka_icon))
if self.CP.carFingerprint == CAR.KIA_RAY_EV: if self.CP.carFingerprint == CAR.KIA_RAY_EV:
self._ray_lkas11_active = True self._ray_lkas11_active = True
if getattr(self.FPCP, "flags", 0) & HyundaiStarPilotFlags.HAS_LKAS12:
can_sends.append(hyundaican.create_lkas12(self.packer, CS.lkas12))
# Button messages # Button messages
if not self.long_active_ecu: if not self.long_active_ecu:
@@ -850,9 +852,6 @@ class CarController(CarControllerBase):
) )
# steering control # steering control
# The first-generation Electrified GV70 expects the synthesized LKAS status
# payload. Forwarding its stock status bits leaves lane-safety state asserted
# while StarPilot is suppressing the stock LFA path.
preserve_stock_lkas = bool(self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING) and \ preserve_stock_lkas = bool(self.CP.flags & HyundaiFlags.CANFD_LKA_STEERING) and \
not self.long_active_ecu and self.CP.carFingerprint != CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and \ not self.long_active_ecu and self.CP.carFingerprint != CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and \
preserve_stock_canfd_lkas_status(self.CP.carFingerprint) preserve_stock_canfd_lkas_status(self.CP.carFingerprint)
+13 -4
View File
@@ -36,6 +36,8 @@ CLASSIC_MEDIA_BUTTON_CARS = frozenset({
def get_non_scc_cruise_signals(CP) -> tuple[str, str, str, str, str, str]: def get_non_scc_cruise_signals(CP) -> tuple[str, str, str, str, str, str]:
if CP.carFingerprint == CAR.KIA_RAY_EV:
return "LABEL11", "CC_React", "LABEL11", "CC_Engaged", "E_EMS11", "Cruise_Limit_Target"
if CP.flags & HyundaiFlags.EV: if CP.flags & HyundaiFlags.EV:
return "LABEL11", "CC_React", "EMS12", "ACC_ACT", "E_EMS11", "Cruise_Limit_Target" return "LABEL11", "CC_React", "EMS12", "ACC_ACT", "E_EMS11", "Cruise_Limit_Target"
if CP.flags & HyundaiFlags.HYBRID: if CP.flags & HyundaiFlags.HYBRID:
@@ -142,6 +144,7 @@ class CarState(CarStateBase):
self.msg_364 = {} self.msg_364 = {}
self.lfa_block_msg = {} self.lfa_block_msg = {}
self.stock_lkas_msg = {} self.stock_lkas_msg = {}
self.lkas12 = {}
self.stock_lfa_msg = {} self.stock_lfa_msg = {}
self.stock_lfahda_cluster_msg = {} self.stock_lfahda_cluster_msg = {}
self.stock_camera_lead_visible = False self.stock_camera_lead_visible = False
@@ -347,9 +350,7 @@ class CarState(CarStateBase):
# cruise state # cruise state
no_scc = bool(self.CP.flags & HyundaiFlags.NON_SCC) no_scc = bool(self.CP.flags & HyundaiFlags.NON_SCC)
if self.CP.carFingerprint == CAR.KIA_RAY_EV: if no_scc:
pass
elif no_scc:
cruise_available_msg, cruise_available_sig, cruise_enabled_msg, cruise_enabled_sig, cruise_speed_msg, cruise_speed_sig = get_non_scc_cruise_signals(self.CP) cruise_available_msg, cruise_available_sig, cruise_enabled_msg, cruise_enabled_sig, cruise_speed_msg, cruise_speed_sig = get_non_scc_cruise_signals(self.CP)
ret.cruiseState.available = cp.vl[cruise_available_msg][cruise_available_sig] != 0 ret.cruiseState.available = cp.vl[cruise_available_msg][cruise_available_sig] != 0
ret.cruiseState.enabled = cp.vl[cruise_enabled_msg][cruise_enabled_sig] != 0 ret.cruiseState.enabled = cp.vl[cruise_enabled_msg][cruise_enabled_sig] != 0
@@ -440,6 +441,8 @@ class CarState(CarStateBase):
self.lkas11 = {} self.lkas11 = {}
else: else:
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"]) self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
if getattr(self.FPCP, "flags", 0) & HyundaiStarPilotFlags.HAS_LKAS12:
self.lkas12 = copy.copy(cp_cam.vl["LKAS12"])
self.clu11 = copy.copy(cp.vl["CLU11"]) self.clu11 = copy.copy(cp.vl["CLU11"])
self.steer_state = cp.vl["MDPS12"]["CF_Mdps_ToiActive"] # 0 NOT ACTIVE, 1 ACTIVE self.steer_state = cp.vl["MDPS12"]["CF_Mdps_ToiActive"] # 0 NOT ACTIVE, 1 ACTIVE
if not self.main_cruise_tracking: if not self.main_cruise_tracking:
@@ -722,6 +725,12 @@ class CarState(CarStateBase):
("BCM_PO_11", 0), ("BCM_PO_11", 0),
("CLU13", 0), ("CLU13", 0),
] ]
if CP.carFingerprint == CAR.KIA_RAY_EV:
msgs += [
("LABEL11", 10),
("E_EMS11", 100),
("ELECT_GEAR", 100),
]
if CP.carFingerprint in CLASSIC_MEDIA_BUTTON_CARS: if CP.carFingerprint in CLASSIC_MEDIA_BUTTON_CARS:
# Steering-wheel media switches are event-driven on the refresh Elantra. # Steering-wheel media switches are event-driven on the refresh Elantra.
msgs.append(("GW_SWRC_PE", 0)) msgs.append(("GW_SWRC_PE", 0))
@@ -730,7 +739,7 @@ class CarState(CarStateBase):
parsers = { parsers = {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], msgs, 0), Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], msgs, 0),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], 2), Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS12", 0)], 2),
} }
if CP.carFingerprint in ALT_BUS_LDA_BUTTON_CARS: if CP.carFingerprint in ALT_BUS_LDA_BUTTON_CARS:
parsers[Bus.alt] = CANParser(DBC[CP.carFingerprint][Bus.pt], [("CLU13", 0)], 1) parsers[Bus.alt] = CANParser(DBC[CP.carFingerprint][Bus.pt], [("CLU13", 0)], 1)
@@ -81,6 +81,10 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
values["CF_Lkas_LdwsActivemode"] = 2 values["CF_Lkas_LdwsActivemode"] = 2
if CP.carFingerprint == CAR.KIA_RAY_EV: if CP.carFingerprint == CAR.KIA_RAY_EV:
if not enabled:
values["CF_Lkas_LdwsActivemode"] = lkas11["CF_Lkas_LdwsActivemode"]
values["CF_Lkas_LdwsSysState"] = lkas11["CF_Lkas_LdwsSysState"]
values["CF_Lkas_FcwOpt_USM"] = lkas11["CF_Lkas_FcwOpt_USM"]
values["CF_Lkas_LdwsOpt_USM"] = 0 values["CF_Lkas_LdwsOpt_USM"] = 0
values["CF_Lkas_Chksum"] = 0 values["CF_Lkas_Chksum"] = 0
@@ -102,6 +106,19 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
return packer.make_can_msg("LKAS11", 0, values) return packer.make_can_msg("LKAS11", 0, values)
def create_lkas12(packer, lkas12):
values = {s: lkas12[s] for s in (
"CF_Lkas_TsrSlifOpt",
"CF_LkasTsrStatus",
"CF_Lkas_TsrSpeed_Display_Clu",
"CF_LkasTsrSpeed_Display_Navi",
"CF_Lkas_TsrAddinfo_Display",
"CF_Lkas_Daw_USM",
) if s in lkas12}
values["CF_LkasDawStatus"] = 0
return packer.make_can_msg("LKAS12", 0, values)
def create_checksum_can_canfd_blended(packer, bus, addr, values): def create_checksum_can_canfd_blended(packer, bus, addr, values):
dat = packer.make_can_msg(addr, bus, values)[1] dat = packer.make_can_msg(addr, bus, values)[1]
return hyundai_checksum(dat[1:8]) return hyundai_checksum(dat[1:8])
@@ -63,61 +63,6 @@ def _update_checksum(packer, address: int, dat: bytearray) -> None:
_set_value(dat, sig_checksum, checksum) _set_value(dat, sig_checksum, checksum)
def _set_little_endian_bits(dat: bytearray, lsb: int, size: int, value: int) -> None:
"""Write the legacy HDA-II field layout without changing the generated DBC aliases."""
value &= (1 << size) - 1
bit = lsb
remaining = size
while remaining:
byte = bit // 8
shift = bit % 8
chunk_size = min(remaining, 8 - shift)
mask = ((1 << chunk_size) - 1) << shift
dat[byte] = (dat[byte] & ~mask) | ((value & ((1 << chunk_size) - 1)) << shift)
value >>= chunk_size
bit += chunk_size
remaining -= chunk_size
def _create_gv70_lka_status_msg(packer, CAN, message_name: str, bus: int, enabled: bool,
lat_active: bool, apply_torque: int):
values = {
"LKA_MODE": 2,
"LKA_ICON": 2 if enabled else 1,
"TORQUE_REQUEST": apply_torque,
"STEER_REQ": 1 if lat_active else 0,
"LKA_ASSIST": 0,
"STEER_MODE": 0,
"DAMP_FACTOR": 100,
}
address, raw, _ = packer.make_can_msg(message_name, bus, values)
dat = bytearray(raw)
legacy_fields = (
(24, 3, 2),
(27, 3, 0),
(30, 2, 0),
(32, 2, 0),
(34, 2, 0),
(36, 2, 0),
(38, 3, 2 if enabled else 1),
(52, 2, 1 if lat_active else 0),
(54, 2, 0),
(56, 1, 0),
(60, 4, 0),
(80, 2, 0),
)
for lsb, size, value in legacy_fields:
_set_little_endian_bits(dat, lsb, size, value)
_set_little_endian_bits(dat, 64 if message_name == "LKAS" else 104, 8, 100)
if message_name == "LKAS":
_set_little_endian_bits(dat, 84, 3, 0)
_update_checksum(packer, address, dat)
return address, bytes(dat), bus
def _create_angle_lfa_msg(packer, CAN, values, apply_angle: float, lat_active: bool, torque_reduction_gain: float): def _create_angle_lfa_msg(packer, CAN, values, apply_angle: float, lat_active: bool, torque_reduction_gain: float):
address = packer.dbc.name_to_msg["LFA"].address address = packer.dbc.name_to_msg["LFA"].address
dat = packer.pack(address, values) dat = packer.pack(address, values)
@@ -156,13 +101,6 @@ def create_steering_messages(packer, CP, CAN, enabled, lat_active, apply_torque,
if lka_icon is None: if lka_icon is None:
lka_icon = 2 if enabled else 1 lka_icon = 2 if enabled else 1
if CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and CP.flags & HyundaiFlags.CANFD_LKA_STEERING:
ret = []
if CP.openpilotLongitudinalControl:
ret.append(_create_gv70_lka_status_msg(packer, CAN, "LFA", CAN.ECAN, enabled, lat_active, apply_torque))
ret.append(_create_gv70_lka_status_msg(packer, CAN, "LKAS", CAN.ACAN, enabled, lat_active, apply_torque))
return ret
angle_lkas_alt = CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and CP.flags & HyundaiFlags.CANFD_LKA_STEERING_ALT angle_lkas_alt = CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and CP.flags & HyundaiFlags.CANFD_LKA_STEERING_ALT
control_values = { control_values = {
@@ -710,7 +710,7 @@ class TestHyundaiFingerprint:
assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 0 assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 0
assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 0 assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 0
def test_kia_ray_ev_preserves_stock_lkas_option(self): def test_kia_ray_ev_preserves_stock_inactive_lkas_status(self):
fingerprint = gen_empty_fingerprint() fingerprint = gen_empty_fingerprint()
fingerprint[2][0x485] = 4 fingerprint[2][0x485] = 4
CP = CarInterface.get_params(CAR.KIA_RAY_EV, fingerprint, [], False, False, False, None) CP = CarInterface.get_params(CAR.KIA_RAY_EV, fingerprint, [], False, False, False, None)
@@ -718,15 +718,46 @@ class TestHyundaiFingerprint:
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt]) packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS11", 0)], 0) parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS11", 0)], 0)
lkas11 = parser.vl["LKAS11"]
lkas11.update({
"CF_Lkas_LdwsActivemode": 0,
"CF_Lkas_LdwsSysState": 1,
"CF_Lkas_FcwOpt_USM": 1,
})
msg = hyundaican.create_lkas11( msg = hyundaican.create_lkas11(
packer, 0, CP, 0, True, False, parser.vl["LKAS11"], False, 4, False, packer, 0, CP, 0, True, False, lkas11, False, 4, False,
True, True, 0, 0, 2,
)
parser.update([(1, [msg])])
assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 0
assert parser.vl["LKAS11"]["CF_Lkas_LdwsSysState"] == 1
assert parser.vl["LKAS11"]["CF_Lkas_LdwsOpt_USM"] == 0
assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 1
def test_kia_ray_ev_uses_active_lkas_status_when_enabled(self):
fingerprint = gen_empty_fingerprint()
fingerprint[2][0x485] = 4
CP = CarInterface.get_params(CAR.KIA_RAY_EV, fingerprint, [], False, False, False, None)
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS11", 0)], 0)
lkas11 = parser.vl["LKAS11"]
lkas11.update({
"CF_Lkas_LdwsActivemode": 0,
"CF_Lkas_LdwsSysState": 1,
"CF_Lkas_FcwOpt_USM": 1,
})
msg = hyundaican.create_lkas11(
packer, 0, CP, 0, True, False, lkas11, False, 4, True,
True, True, 0, 0, 2, True, True, 0, 0, 2,
) )
parser.update([(1, [msg])]) parser.update([(1, [msg])])
assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 3 assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 3
assert parser.vl["LKAS11"]["CF_Lkas_LdwsSysState"] == 4
assert parser.vl["LKAS11"]["CF_Lkas_LdwsOpt_USM"] == 0 assert parser.vl["LKAS11"]["CF_Lkas_LdwsOpt_USM"] == 0
assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 1 assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 2
def test_kia_ray_ev_delays_first_lkas11(self): def test_kia_ray_ev_delays_first_lkas11(self):
fingerprint = gen_empty_fingerprint() fingerprint = gen_empty_fingerprint()
@@ -796,6 +827,37 @@ class TestHyundaiFingerprint:
palisade_2023 = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, gen_empty_fingerprint(), [], True, False, False, None) palisade_2023 = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, gen_empty_fingerprint(), [], True, False, False, None)
assert palisade_2023.safetyConfigs[-1].safetyParam & HyundaiStarPilotSafetyFlags.HAS_LDA_BUTTON assert palisade_2023.safetyConfigs[-1].safetyParam & HyundaiStarPilotSafetyFlags.HAS_LDA_BUTTON
def test_lkas12_da_warning_is_filtered_for_camera_fingerprint(self):
fingerprint = gen_empty_fingerprint()
fingerprint[2][0x53E] = 6
CP = CarInterface.get_params(CAR.HYUNDAI_SONATA_HYBRID, fingerprint, [], False, False, False, None)
FPCP = CarInterface.get_starpilot_params(CAR.HYUNDAI_SONATA_HYBRID, fingerprint, [], CP, get_test_toggles())
assert FPCP.flags & HyundaiStarPilotFlags.HAS_LKAS12
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
stock = {
"CF_Lkas_TsrSlifOpt": 3,
"CF_LkasTsrStatus": 2,
"CF_Lkas_TsrSpeed_Display_Clu": 80,
"CF_LkasTsrSpeed_Display_Navi": 70,
"CF_Lkas_TsrAddinfo_Display": 1,
"CF_Lkas_Daw_USM": 0,
"CF_LkasDawStatus": 1,
}
msg = hyundaican.create_lkas12(packer, stock)
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS12", 0)], 0)
parser.update([(1, [msg])])
assert parser.can_valid
assert parser.vl["LKAS12"]["CF_LkasDawStatus"] == 0
assert parser.vl["LKAS12"]["CF_Lkas_TsrSpeed_Display_Clu"] == 80
no_lkas12 = CarInterface.get_params(CAR.HYUNDAI_SONATA_HYBRID, gen_empty_fingerprint(), [], False, False, False, None)
no_lkas12_fpcp = CarInterface.get_starpilot_params(
CAR.HYUNDAI_SONATA_HYBRID, gen_empty_fingerprint(), [], no_lkas12, get_test_toggles(),
)
assert not (no_lkas12_fpcp.flags & HyundaiStarPilotFlags.HAS_LKAS12)
def test_carnival_lka_button_does_not_enable_angle_steering_safety(self): def test_carnival_lka_button_does_not_enable_angle_steering_safety(self):
fingerprint = gen_empty_fingerprint() fingerprint = gen_empty_fingerprint()
fingerprint[0][0x391] = 8 fingerprint[0][0x391] = 8
@@ -864,7 +926,7 @@ class TestHyundaiFingerprint:
(CAR.HYUNDAI_ELANTRA_2022_NON_SCC, ("EMS16", "LVR12"), ()), (CAR.HYUNDAI_ELANTRA_2022_NON_SCC, ("EMS16", "LVR12"), ()),
(CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC, ("E_CRUISE_CONTROL", "ELECT_GEAR"), ("EMS16",)), (CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC, ("E_CRUISE_CONTROL", "ELECT_GEAR"), ("EMS16",)),
(CAR.HYUNDAI_KONA_EV_NON_SCC, ("LABEL11", "EMS12", "E_EMS11"), ()), (CAR.HYUNDAI_KONA_EV_NON_SCC, ("LABEL11", "EMS12", "E_EMS11"), ()),
(CAR.KIA_RAY_EV, ("E_EMS11",), ("LABEL11", "EMS12", "SCC11", "SCC12")), (CAR.KIA_RAY_EV, ("LABEL11", "E_EMS11", "ELECT_GEAR"), ("EMS12", "SCC11", "SCC12")),
]) ])
def test_non_scc_cruise_message_selection(self, candidate, expected_msgs, unexpected_msgs): def test_non_scc_cruise_message_selection(self, candidate, expected_msgs, unexpected_msgs):
toggles = get_test_toggles() toggles = get_test_toggles()
@@ -883,6 +945,26 @@ class TestHyundaiFingerprint:
assert not ret.cruiseState.enabled assert not ret.cruiseState.enabled
assert ret.cruiseState.speed == 0 assert ret.cruiseState.speed == 0
def test_kia_ray_ev_decodes_cruise_state(self):
toggles = get_test_toggles()
CP = CarInterface.get_params(CAR.KIA_RAY_EV, gen_empty_fingerprint(), [], True, False, False, toggles)
FPCP = CarInterface.get_starpilot_params(CAR.KIA_RAY_EV, gen_empty_fingerprint(), [], CP, toggles)
car_state = CarState(CP, FPCP)
can_parsers = car_state.get_can_parsers(CP)
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
can_parsers[Bus.pt].update([(1_000_000_000, [
packer.make_can_msg("LABEL11", 0, {"CC_React": 1, "CC_Engaged": 1}),
packer.make_can_msg("E_EMS11", 0, {"Cruise_Limit_Target": 10, "Accel_Pedal_Pos": 0}),
packer.make_can_msg("ELECT_GEAR", 0, {"Elect_Gear_Shifter": 5}),
])])
ret, _ = car_state.update(can_parsers, toggles)
assert ret.cruiseState.available
assert ret.cruiseState.enabled
assert ret.cruiseState.speed == pytest.approx(10 * 0.2777778)
def test_hyundai_redneck_cruise_availability(self, monkeypatch): def test_hyundai_redneck_cruise_availability(self, monkeypatch):
class FakeParams: class FakeParams:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -2288,7 +2370,7 @@ class TestHyundaiFingerprint:
assert parser.vl["LKAS_ALT"]["ADAS_ACIAnglTqRedcGainVal"] == pytest.approx(0.0) assert parser.vl["LKAS_ALT"]["ADAS_ACIAnglTqRedcGainVal"] == pytest.approx(0.0)
assert parser.vl["LKAS_ALT"]["ADAS_StrAnglReqVal"] == pytest.approx(8.5) assert parser.vl["LKAS_ALT"]["ADAS_StrAnglReqVal"] == pytest.approx(8.5)
def test_gv70_electrified_synthesizes_lkas_status_payload(self): def test_gv70_electrified_uses_generic_lkas_status_payload(self):
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)
@@ -2331,9 +2413,11 @@ class TestHyundaiFingerprint:
parser.update([(1, lkas_msgs)]) parser.update([(1, lkas_msgs)])
assert parser.can_valid assert parser.can_valid
assert parser.vl["LKAS"]["HAS_LANE_SAFETY"] == 0 assert parser.vl["LKAS"]["HAS_LANE_SAFETY"] == 0
assert parser.vl["LKAS"]["DAMP_FACTOR"] == 100 assert parser.vl["LKAS"]["DAMP_FACTOR"] == 0
assert parser.vl["LKAS"]["TORQUE_REQUEST"] == 0 assert parser.vl["LKAS"]["TORQUE_REQUEST"] == 0
assert parser.vl["LKAS"]["STEER_REQ"] == 1 assert parser.vl["LKAS"]["STEER_REQ"] == 1
assert parser.vl["LKAS"]["STEER_MODE"] == 0
assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0
CP.openpilotLongitudinalControl = True 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)
@@ -124,6 +124,7 @@ class HyundaiStarPilotSafetyFlags(IntFlag):
class HyundaiStarPilotFlags(IntFlag): class HyundaiStarPilotFlags(IntFlag):
SPEED_LIMIT_AVAILABLE = 1 SPEED_LIMIT_AVAILABLE = 1
MAIN_CRUISE_STATE_TRACKING = 2 ** 2 MAIN_CRUISE_STATE_TRACKING = 2 ** 2
HAS_LKAS12 = 2 ** 9
class HyundaiFlags(IntFlag): class HyundaiFlags(IntFlag):
+3
View File
@@ -240,6 +240,9 @@ class CarInterfaceBase(ABC):
if 0x1FA in fingerprint[CAN.ECAN]: if 0x1FA in fingerprint[CAN.ECAN]:
fp_ret.flags |= HyundaiStarPilotFlags.SPEED_LIMIT_AVAILABLE.value fp_ret.flags |= HyundaiStarPilotFlags.SPEED_LIMIT_AVAILABLE.value
if not (CP.flags & HyundaiFlags.CANFD) and 0x53E in fingerprint[2]:
fp_ret.flags |= HyundaiStarPilotFlags.HAS_LKAS12.value
fp_ret.redneckCruiseAvailable = bool(CP.flags & HyundaiFlags.NON_SCC) and not bool(CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS) fp_ret.redneckCruiseAvailable = bool(CP.flags & HyundaiFlags.NON_SCC) and not bool(CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS)
if fp_ret.redneckCruiseAvailable and params.get_bool("RedneckCruise"): if fp_ret.redneckCruiseAvailable and params.get_bool("RedneckCruise"):
fp_ret.pcmCruiseSpeed = False fp_ret.pcmCruiseSpeed = False
@@ -1500,6 +1500,7 @@ BO_ 913 BCM_PO_11: 8 Vector__XXX
BO_ 1426 LABEL11: 8 XXX BO_ 1426 LABEL11: 8 XXX
SG_ CC_React : 34|1@1+ (1,0) [0|1] "" XXX SG_ CC_React : 34|1@1+ (1,0) [0|1] "" XXX
SG_ CC_Engaged : 35|1@1+ (1,0) [0|1] "" XXX
BO_ 910 WHL_SPD12_FS: 5 iBAU BO_ 910 WHL_SPD12_FS: 5 iBAU
SG_ CRC : 0|8@1+ (1,0) [0|0] "" Vector__XXX SG_ CRC : 0|8@1+ (1,0) [0|0] "" Vector__XXX
@@ -29,6 +29,7 @@ const LongitudinalLimits HYUNDAI_LONG_LIMITS = {
{0x340, 0, 8, .check_relay = true}, /* LKAS11 Bus 0 */ \ {0x340, 0, 8, .check_relay = true}, /* LKAS11 Bus 0 */ \
{0x4F1, scc_bus, 4, .check_relay = false}, /* CLU11 Bus 0 (radar-SCC) or 2 (camera-SCC) */ \ {0x4F1, scc_bus, 4, .check_relay = false}, /* CLU11 Bus 0 (radar-SCC) or 2 (camera-SCC) */ \
{0x485, 0, (can_refresh) ? 8 : 4, .check_relay = true}, /* LFAHDA_MFC Bus 0 */ \ {0x485, 0, (can_refresh) ? 8 : 4, .check_relay = true}, /* LFAHDA_MFC Bus 0 */ \
{0x53E, 0, 6, .check_relay = false}, /* LKAS12 replacement after camera advertises it */ \
#define HYUNDAI_LONG_COMMON_TX_MSGS(scc_bus, can_refresh) \ #define HYUNDAI_LONG_COMMON_TX_MSGS(scc_bus, can_refresh) \
HYUNDAI_COMMON_TX_MSGS(scc_bus, can_refresh) \ HYUNDAI_COMMON_TX_MSGS(scc_bus, can_refresh) \
@@ -140,6 +141,12 @@ static uint32_t hyundai_get_checksum(const CANPacket_t *msg) {
return chksum; return chksum;
} }
static void hyundai_rx_all_hook(const CANPacket_t *msg) {
if ((msg->addr == 0x53EU) && (msg->bus == 2U) && (GET_LEN(msg) == 6U)) {
hyundai_has_lkas12 = true;
}
}
static uint32_t hyundai_compute_checksum(const CANPacket_t *msg) { static uint32_t hyundai_compute_checksum(const CANPacket_t *msg) {
uint8_t chksum = 0; uint8_t chksum = 0;
if (msg->addr == 0x386U) { if (msg->addr == 0x386U) {
@@ -284,6 +291,10 @@ static bool hyundai_tx_hook(const CANPacket_t *msg) {
bool tx = true; bool tx = true;
if ((msg->addr == 0x53EU) && !hyundai_has_lkas12) {
tx = false;
}
// FCA11: Block any potential actuation. The blended HDA II layout uses // FCA11: Block any potential actuation. The blended HDA II layout uses
// different static fields, but its explicit AEB/FCA request bits stay zero. // different static fields, but its explicit AEB/FCA request bits stay zero.
if (msg->addr == 0x38DU) { if (msg->addr == 0x38DU) {
@@ -695,6 +706,7 @@ static safety_config hyundai_legacy_init(uint16_t param) {
const safety_hooks hyundai_hooks = { const safety_hooks hyundai_hooks = {
.init = hyundai_init, .init = hyundai_init,
.rx = hyundai_rx_hook, .rx = hyundai_rx_hook,
.rx_all = hyundai_rx_all_hook,
.tx = hyundai_tx_hook, .tx = hyundai_tx_hook,
.get_counter = hyundai_get_counter, .get_counter = hyundai_get_counter,
.get_checksum = hyundai_get_checksum, .get_checksum = hyundai_get_checksum,
@@ -704,6 +716,7 @@ const safety_hooks hyundai_hooks = {
const safety_hooks hyundai_legacy_hooks = { const safety_hooks hyundai_legacy_hooks = {
.init = hyundai_legacy_init, .init = hyundai_legacy_init,
.rx = hyundai_rx_hook, .rx = hyundai_rx_hook,
.rx_all = hyundai_rx_all_hook,
.tx = hyundai_tx_hook, .tx = hyundai_tx_hook,
.get_counter = hyundai_get_counter, .get_counter = hyundai_get_counter,
.get_checksum = hyundai_get_checksum, .get_checksum = hyundai_get_checksum,
@@ -63,6 +63,9 @@ bool hyundai_cancel_button_enable = false;
extern bool hyundai_can_refresh_msgs; extern bool hyundai_can_refresh_msgs;
bool hyundai_can_refresh_msgs = false; bool hyundai_can_refresh_msgs = false;
extern bool hyundai_has_lkas12;
bool hyundai_has_lkas12 = false;
extern bool hyundai_elantra_hev_2024; extern bool hyundai_elantra_hev_2024;
bool hyundai_elantra_hev_2024 = false; bool hyundai_elantra_hev_2024 = false;
@@ -106,6 +109,7 @@ void hyundai_common_init(uint16_t param) {
hyundai_non_scc = GET_FLAG(param, HYUNDAI_PARAM_NON_SCC); hyundai_non_scc = GET_FLAG(param, HYUNDAI_PARAM_NON_SCC);
hyundai_cancel_button_enable = GET_FLAG(param, HYUNDAI_PARAM_CANCEL_BTN_ENABLE); hyundai_cancel_button_enable = GET_FLAG(param, HYUNDAI_PARAM_CANCEL_BTN_ENABLE);
hyundai_can_refresh_msgs = GET_FLAG(param, HYUNDAI_PARAM_CAN_REFRESH_MSGS); hyundai_can_refresh_msgs = GET_FLAG(param, HYUNDAI_PARAM_CAN_REFRESH_MSGS);
hyundai_has_lkas12 = false;
hyundai_elantra_hev_2024 = hyundai_can_refresh_msgs && hyundai_hybrid_gas_signal && hyundai_camera_scc; hyundai_elantra_hev_2024 = hyundai_can_refresh_msgs && hyundai_hybrid_gas_signal && hyundai_camera_scc;
hyundai_aol_main_lkas_sync = false; hyundai_aol_main_lkas_sync = false;
@@ -427,6 +427,18 @@ def test_hyundai_starpilot_rx_sources():
safety.safety_rx_hook(libsafety_py.make_CANPacket(0x421, 0, bytes(8))) safety.safety_rx_hook(libsafety_py.make_CANPacket(0x421, 0, bytes(8)))
def test_hyundai_lkas12_tx_requires_stock_camera_message():
safety = libsafety_py.libsafety
assert safety.set_safety_hooks(CarParams.SafetyModel.hyundai, 0) == 0
safety.init_tests()
lkas12 = libsafety_py.make_CANPacket(0x53E, 0, bytes(6))
assert not safety.safety_tx_hook(lkas12)
safety.safety_rx_hook(libsafety_py.make_CANPacket(0x53E, 2, bytes(6)))
assert safety.safety_tx_hook(lkas12)
class TestHyundaiLongitudinalSafety(HyundaiLongitudinalBase, TestHyundaiSafety): class TestHyundaiLongitudinalSafety(HyundaiLongitudinalBase, TestHyundaiSafety):
TX_MSGS = [[0x340, 0], [0x4F1, 0], [0x485, 0], [0x420, 0], [0x421, 0], [0x50A, 0], [0x389, 0], [0x4A2, 0], [0x38D, 0], [0x483, 0], [0x7D0, 0]] TX_MSGS = [[0x340, 0], [0x4F1, 0], [0x485, 0], [0x420, 0], [0x421, 0], [0x50A, 0], [0x389, 0], [0x4A2, 0], [0x38D, 0], [0x483, 0], [0x7D0, 0]]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
extern const uint8_t gitversion[19]; extern const uint8_t gitversion[19];
const uint8_t gitversion[19] = "DEV-3ebc6b99-DEBUG"; const uint8_t gitversion[19] = "DEV-a3bdcf24-DEBUG";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
DEV-3ebc6b99-DEBUG DEV-a3bdcf24-DEBUG
+46 -1
View File
@@ -26,6 +26,7 @@ Usage:
Commands: Commands:
c3 Launch the large raylib UI from the isolated host cache. c3 Launch the large raylib UI from the isolated host cache.
c4 Launch the small raylib UI from the isolated host cache. c4 Launch the small raylib UI from the isolated host cache.
galaxy Launch the local Galaxy web UI from the isolated host cache.
onroad Launch replay plus desktop UI(s) from the isolated host cache. onroad Launch replay plus desktop UI(s) from the isolated host cache.
replay Build and run replay from the isolated host cache. replay Build and run replay from the isolated host cache.
cabana Build and run cabana from the isolated host cache. cabana Build and run cabana from the isolated host cache.
@@ -484,6 +485,47 @@ launch_c4() {
run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@" run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@"
} }
pick_free_galaxy_port() {
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
import socket
# Desktop ZMQ hashes replay service names into ports 8023-65535. Keep Galaxy
# below that range so its HTTP server never steals a replay service port.
for port in range(4600, 8023):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("0.0.0.0", port))
except OSError:
continue
print(port)
raise SystemExit(0)
raise SystemExit("Unable to find a free local Galaxy port.")
PY
}
launch_galaxy() {
sync_worktree
ensure_host_python_extensions
local port
port="$(pick_free_galaxy_port)"
local galaxy_dir="${HOME}/.comma/starpilot/data/galaxy"
echo "Starting local Galaxy session on port ${port}..."
(
cd "${WORK_DIR}"
setup_build_env
export_workdir_pythonpath
export SP_GALAXY_DIR="${galaxy_dir}"
export SP_GALAXY_HOST="0.0.0.0"
export SP_GALAXY_PORT="${port}"
export SP_GALAXY_DEBUG="${SP_GALAXY_DEBUG:-1}"
export SP_GALAXY_RELOAD="${SP_GALAXY_RELOAD:-0}"
exec "${WORK_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy
)
}
launch_onroad() { launch_onroad() {
local jobs local jobs
jobs="$(default_jobs)" jobs="$(default_jobs)"
@@ -588,7 +630,7 @@ main() {
help|-h|--help) help|-h|--help)
usage usage
;; ;;
c3|c4|onroad|replay|shell|python|pytest) c3|c4|galaxy|onroad|replay|shell|python|pytest)
set_host_bucket "shared" set_host_bucket "shared"
acquire_host_lock "${command} $*" acquire_host_lock "${command} $*"
;; ;;
@@ -629,6 +671,9 @@ main() {
c4) c4)
launch_c4 "$@" launch_c4 "$@"
;; ;;
galaxy)
launch_galaxy "$@"
;;
onroad) onroad)
launch_onroad "$@" launch_onroad "$@"
;; ;;
+3 -2
View File
@@ -56,13 +56,14 @@ def build_compile_env(*, supercombo: bool = False) -> dict[str, str]:
existing_pythonpath = env.get("PYTHONPATH", "") existing_pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = f"{REPO_ROOT}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(REPO_ROOT) env["PYTHONPATH"] = f"{REPO_ROOT}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(REPO_ROOT)
defaults = { defaults = {
"DEBUG": "0",
"FLOAT16": "1", "FLOAT16": "1",
"IMAGE": "1" if supercombo else "2", "IMAGE": "1" if supercombo else "2",
"JIT_BATCH_SIZE": "0", "JIT_BATCH_SIZE": "0",
"NOLOCALS": "1", "NOLOCALS": "1",
"OPENPILOT_HACKS": "1", "OPENPILOT_HACKS": "1",
} } | ({} if supercombo else {
"DEBUG": "0",
})
for key, default in defaults.items(): for key, default in defaults.items():
try: try:
int(str(env.get(key)), 0) int(str(env.get(key)), 0)
+1 -1
View File
@@ -31,7 +31,7 @@ OPENPILOT_REPO = "commaai/openpilot"
RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources") RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources") HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources")
RESOURCE_BRANCH = "Models" RESOURCE_BRANCH = "Models"
MANIFEST_VERSION = "v25" MANIFEST_VERSION = "v24"
DEFAULT_BEHAVIOR_VERSION = "v16" DEFAULT_BEHAVIOR_VERSION = "v16"
DEVICE_ROOT = "/data/openpilot" DEVICE_ROOT = "/data/openpilot"
REPOSITORY_FILE_LIMIT = 100_000_000 REPOSITORY_FILE_LIMIT = 100_000_000
+2 -2
View File
@@ -79,14 +79,14 @@ def test_runtime_scan_excludes_model_weights_but_flags_runtime_code():
def test_update_manifest_replaces_one_entry(tmp_path: Path): def test_update_manifest_replaces_one_entry(tmp_path: Path):
manifest = tmp_path / "model_names_v25.json" manifest = tmp_path / "model_names_v24.json"
manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n") manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n")
info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16") info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16")
path = update_manifest( path = update_manifest(
tmp_path, tmp_path,
info, info,
{"size": 123, "sha256": "a" * 64}, {"size": 123, "sha256": "a" * 64},
"v25", "v24",
) )
payload = json.loads(path.read_text()) payload = json.loads(path.read_text())
assert len(payload["models"]) == 2 assert len(payload["models"]) == 2
@@ -253,12 +253,12 @@ GENESIS_G70_FRICTION_CENTER_LAT = 0.28
GENESIS_G70_FRICTION_CENTER_LAT_WIDTH = 0.10 GENESIS_G70_FRICTION_CENTER_LAT_WIDTH = 0.10
GENESIS_G70_FRICTION_CALM_JERK = 0.35 GENESIS_G70_FRICTION_CALM_JERK = 0.35
GENESIS_G70_FRICTION_CALM_JERK_WIDTH = 0.10 GENESIS_G70_FRICTION_CALM_JERK_WIDTH = 0.10
GENESIS_G70_FRICTION_JERK_DEADZONE_MAX = 0.30 GENESIS_G70_FRICTION_JERK_DEADZONE_MAX = 0.39
GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30 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_CENTER_OUTPUT_TAPER_MAX = 0.16 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
@@ -288,11 +288,11 @@ GENESIS_G70_CURVE_UNWIND_LAT = 0.25
GENESIS_G70_CURVE_UNWIND_LAT_WIDTH = 0.12 GENESIS_G70_CURVE_UNWIND_LAT_WIDTH = 0.12
GENESIS_G70_CURVE_UNWIND_JERK = 0.08 GENESIS_G70_CURVE_UNWIND_JERK = 0.08
GENESIS_G70_CURVE_UNWIND_JERK_WIDTH = 0.08 GENESIS_G70_CURVE_UNWIND_JERK_WIDTH = 0.08
GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.36 GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.28
GENESIS_G70_UNWIND_FF_OVERSHOOT = 0.10 GENESIS_G70_UNWIND_FF_OVERSHOOT = 0.18
GENESIS_G70_UNWIND_FF_OVERSHOOT_WIDTH = 0.12 GENESIS_G70_UNWIND_FF_OVERSHOOT_WIDTH = 0.20
GENESIS_G70_UNWIND_FF_JERK = 0.10 GENESIS_G70_UNWIND_FF_JERK = 0.10
GENESIS_G70_UNWIND_FF_JERK_WIDTH = 0.10 GENESIS_G70_UNWIND_FF_JERK_WIDTH = 0.13
GENESIS_G70_UNWIND_FF_SPEED = 18.0 GENESIS_G70_UNWIND_FF_SPEED = 18.0
GENESIS_G70_UNWIND_FF_SPEED_WIDTH = 3.0 GENESIS_G70_UNWIND_FF_SPEED_WIDTH = 3.0
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_MAX = 0.15 GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_MAX = 0.15
@@ -302,7 +302,6 @@ GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_ERROR = 0.18
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_ERROR_WIDTH = 0.15 GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_ERROR_WIDTH = 0.15
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK = 0.15 GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK = 0.15
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH = 0.10 GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH = 0.10
GENESIS_G70_HIGH_SPEED_OVERSHOOT_PHASE_WEIGHT = 0.60
GENESIS_G70_ANGLE_OUTPUT_TAPER_MIN = 0.45 GENESIS_G70_ANGLE_OUTPUT_TAPER_MIN = 0.45
GENESIS_G70_ANGLE_OUTPUT_TAPER_START = 70.0 GENESIS_G70_ANGLE_OUTPUT_TAPER_START = 70.0
GENESIS_G70_ANGLE_OUTPUT_TAPER_WIDTH = 6.0 GENESIS_G70_ANGLE_OUTPUT_TAPER_WIDTH = 6.0
@@ -389,8 +388,8 @@ BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_LAT = 0.17
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_LAT_WIDTH = 0.04 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_LAT_WIDTH = 0.04
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED = 2.5 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED = 2.5
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_WIDTH = 0.7 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_WIDTH = 0.7
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_MAX = 7.2 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_MAX = 8.2
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_MAX_WIDTH = 0.5 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_MAX_WIDTH = 0.6
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SCALE_MIN = 0.62 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SCALE_MIN = 0.62
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_ALPHA_MIN = 0.28 BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_ALPHA_MIN = 0.28
BOLT_2022_2023_CENTER_FRICTION_THRESHOLD_BUMP = 0.080 BOLT_2022_2023_CENTER_FRICTION_THRESHOLD_BUMP = 0.080
@@ -3238,8 +3237,6 @@ def get_genesis_g70_high_speed_error_scale(setpoint: float, measured_lateral_acc
jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK) / jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK) /
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH) GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH)
phase_weight = 1.0 if setpoint * desired_lateral_jerk < 0.0 else 0.45 phase_weight = 1.0 if setpoint * desired_lateral_jerk < 0.0 else 0.45
if setpoint * measured_lateral_accel > 0.0 and abs(measured_lateral_accel) > abs(setpoint):
phase_weight = max(phase_weight, GENESIS_G70_HIGH_SPEED_OVERSHOOT_PHASE_WEIGHT)
reduction = (GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_MAX * speed_weight * error_weight * reduction = (GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_MAX * speed_weight * error_weight *
(0.35 + (0.65 * jerk_weight)) * phase_weight) (0.35 + (0.65 * jerk_weight)) * phase_weight)
return 1.0 - reduction return 1.0 - reduction
@@ -20,7 +20,7 @@ HONDA_ACCORD_STOP_GO_MAX_LATERAL_OFFSET = 1.25
HONDA_ACCORD_STOP_GO_MIN_MODEL_PROB = 0.95 HONDA_ACCORD_STOP_GO_MIN_MODEL_PROB = 0.95
HONDA_ACCORD_STOP_GO_ACCEL_RISE_RATE = 4.0 HONDA_ACCORD_STOP_GO_ACCEL_RISE_RATE = 4.0
HYUNDAI_ELANTRA_LEAD_FOLLOW_JERK_SCALE = 1.25 HYUNDAI_ELANTRA_LEAD_FOLLOW_JERK_SCALE = 1.25
GENESIS_GV70_ELECTRIFIED_LEAD_FOLLOW_JERK_SCALE = 1.35 GENESIS_GV70_ELECTRIFIED_LEAD_FOLLOW_JERK_SCALE = 1.75
FORD_LIGHTNING_LEAD_FOLLOW_JERK_SCALE = 1.35 FORD_LIGHTNING_LEAD_FOLLOW_JERK_SCALE = 1.35
HONDA_CRV_5G_LEAD_FOLLOW_JERK_SCALE = 1.35 HONDA_CRV_5G_LEAD_FOLLOW_JERK_SCALE = 1.35
GM_SILVERADO_EARLY_FOLLOW_MIN_EGO_SPEED = 18.0 GM_SILVERADO_EARLY_FOLLOW_MIN_EGO_SPEED = 18.0
@@ -42,7 +42,7 @@ def test_force_stop_jerk_scale_is_platform_specific():
def test_lead_follow_jerk_scale_is_platform_specific(): def test_lead_follow_jerk_scale_is_platform_specific():
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")) == 1.25 assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021")) == 1.25
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="GENESIS_GV70_ELECTRIFIED_1ST_GEN")) == 1.35 assert get_lead_follow_jerk_scale(SimpleNamespace(brand="hyundai", carFingerprint="GENESIS_GV70_ELECTRIFIED_1ST_GEN")) == 1.75
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="ford", carFingerprint="FORD_F_150_LIGHTNING_MK1")) == 1.35 assert get_lead_follow_jerk_scale(SimpleNamespace(brand="ford", carFingerprint="FORD_F_150_LIGHTNING_MK1")) == 1.35
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="honda", carFingerprint="HONDA_CRV_5G")) == 1.35 assert get_lead_follow_jerk_scale(SimpleNamespace(brand="honda", carFingerprint="HONDA_CRV_5G")) == 1.35
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="other", carFingerprint="OTHER_CAR")) == 1.0 assert get_lead_follow_jerk_scale(SimpleNamespace(brand="other", carFingerprint="OTHER_CAR")) == 1.0
-4
View File
@@ -7,10 +7,6 @@ import struct
from openpilot.system.hardware import HARDWARE, TICI from openpilot.system.hardware import HARDWARE, TICI
os.environ['GMMU'] = '0' os.environ['GMMU'] = '0'
os.environ['DEV'] = 'QCOM' if TICI else 'LLVM' os.environ['DEV'] = 'QCOM' if TICI else 'LLVM'
try:
int(os.getenv('DEBUG', '0'), 0)
except ValueError:
os.environ['DEBUG'] = '0'
from tinygrad.device import Device from tinygrad.device import Device
from tinygrad.tensor import Tensor from tinygrad.tensor import Tensor
import time import time
@@ -1 +1 @@
31902b114b7fb8455af694d83333a86a44112b83be662e064ffbd67e8daafe72 driving_tinygrad.pkl a77db33c2e2d6a7570dc2a4a70c2b877429ee8bd9ca5dfeda74b5a41231aaff9 driving_tinygrad.pkl
+10
View File
@@ -7,6 +7,16 @@ import pyray as rl
from openpilot.common.params import Params from openpilot.common.params import Params
_BORDER_ROUNDNESS = 0.12
_BORDER_RADIUS_MULTIPLE = 3.0
def get_border_roundness(rect: rl.Rectangle, border_width: float) -> float:
"""Keep a rectangular camera inset inside the rounded frame at thin widths."""
min_dimension = max(1.0, min(rect.width, rect.height))
return min(_BORDER_ROUNDNESS, 2.0 * _BORDER_RADIUS_MULTIPLE * border_width / min_dimension)
def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color: def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
@@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_status import ( from openpilot.selfdrive.ui.lib.starpilot_status import (
CEM_OVERRIDE_COLOR, ENGAGED_COLOR, EXPERIMENTAL_COLOR, TRAFFIC_COLOR CEM_OVERRIDE_COLOR, ENGAGED_COLOR, EXPERIMENTAL_COLOR, TRAFFIC_COLOR
) )
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_roundness
@@ -218,6 +219,7 @@ def get_traffic_border_colors() -> tuple[rl.Color, rl.Color] | None:
def render_background_effects(rect: rl.Rectangle, border_width: float): def render_background_effects(rect: rl.Rectangle, border_width: float):
global _smoothed_steer global _smoothed_steer
sm = ui_state.sm sm = ui_state.sm
border_roundness = get_border_roundness(rect, border_width)
# 1. Turn Signal and Blind Spot indicators # 1. Turn Signal and Blind Spot indicators
colors = get_traffic_border_colors() colors = get_traffic_border_colors()
@@ -225,11 +227,11 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
left_color, right_color = colors left_color, right_color = colors
if left_color.a > 0: if left_color.a > 0:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width // 2), int(rect.height)) rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width // 2), int(rect.height))
rl.draw_rectangle_rounded(rect, 0.12, 10, left_color) rl.draw_rectangle_rounded(rect, border_roundness, 10, left_color)
rl.end_scissor_mode() rl.end_scissor_mode()
if right_color.a > 0: if right_color.a > 0:
rl.begin_scissor_mode(int(rect.x + rect.width // 2), int(rect.y), int(rect.width // 2), int(rect.height)) rl.begin_scissor_mode(int(rect.x + rect.width // 2), int(rect.y), int(rect.width // 2), int(rect.height))
rl.draw_rectangle_rounded(rect, 0.12, 10, right_color) rl.draw_rectangle_rounded(rect, border_roundness, 10, right_color)
rl.end_scissor_mode() rl.end_scissor_mode()
# 2. Steering Torque Border # 2. Steering Torque Border
@@ -262,7 +264,7 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
else: else:
rl.begin_scissor_mode(int(rect.x + rect.width - border_width), y_pos, int(border_width), int(visible_height)) rl.begin_scissor_mode(int(rect.x + rect.width - border_width), y_pos, int(border_width), int(visible_height))
rl.draw_rectangle_rounded(rect, 0.12, 10, col) rl.draw_rectangle_rounded(rect, border_roundness, 10, col)
rl.end_scissor_mode() rl.end_scissor_mode()
@@ -21,6 +21,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_
from openpilot.selfdrive.ui.lib.starpilot_status import ( from openpilot.selfdrive.ui.lib.starpilot_status import (
get_screen_edge_color, get_screen_edge_color,
) )
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_roundness
from openpilot.starpilot.common.favorite_slots import ( from openpilot.starpilot.common.favorite_slots import (
build_favorite_slot_options, build_favorite_slot_options,
filter_favorite_slot_options, filter_favorite_slot_options,
@@ -100,8 +101,9 @@ class StarPilotOnroadView(AugmentedRoadView):
def _render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
border_width = self._get_border_width() border_width = self._get_border_width()
border_roundness = get_border_roundness(rect, border_width)
border_color = get_pulse_glide_border_color(ui_state.sm, get_screen_edge_color(ui_state)) border_color = get_pulse_glide_border_color(ui_state.sm, get_screen_edge_color(ui_state))
rl.draw_rectangle_rounded(rect, 0.12, 10, border_color) rl.draw_rectangle_rounded(rect, border_roundness, 10, border_color)
render_background_effects(rect, border_width) render_background_effects(rect, border_width)
# The favorite menu has first claim on the lower-left gesture. Filtering # The favorite menu has first claim on the lower-left gesture. Filtering
@@ -159,7 +161,8 @@ class StarPilotOnroadView(AugmentedRoadView):
def _draw_border(self, rect: rl.Rectangle): def _draw_border(self, rect: rl.Rectangle):
border_width = self._get_border_width() border_width = self._get_border_width()
rl.draw_rectangle_rounded_lines_ex(rect, 0.12, 10, border_width, rl.BLACK) border_roundness = get_border_roundness(rect, border_width)
rl.draw_rectangle_rounded_lines_ex(rect, border_roundness, 10, border_width, rl.BLACK)
border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width, border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width,
rect.width - 2 * border_width, rect.height - 2 * border_width) rect.width - 2 * border_width, rect.height - 2 * border_width)
render_overlay(border_rect, border_width) render_overlay(border_rect, border_width)
@@ -1,6 +1,8 @@
import importlib.util import importlib.util
import math
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
MODULE_PATH = Path(__file__).resolve().parents[1] / "lib" / "starpilot_visuals.py" MODULE_PATH = Path(__file__).resolve().parents[1] / "lib" / "starpilot_visuals.py"
@@ -9,6 +11,7 @@ MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC is not None and SPEC.loader is not None assert SPEC is not None and SPEC.loader is not None
SPEC.loader.exec_module(MODULE) SPEC.loader.exec_module(MODULE)
lead_indicator_enabled = MODULE.lead_indicator_enabled lead_indicator_enabled = MODULE.lead_indicator_enabled
get_border_roundness = MODULE.get_border_roundness
class FakeParams: class FakeParams:
@@ -29,6 +32,21 @@ class FakeParams:
class TestStarPilotVisuals(unittest.TestCase): class TestStarPilotVisuals(unittest.TestCase):
def test_border_roundness_contains_camera_corner(self):
rect = SimpleNamespace(width=2160, height=1080)
base_width = 30
min_dimension = min(rect.width, rect.height)
for scale in (25, 50, 65, 100, 250):
border_width = round(base_width * scale / 100)
roundness = get_border_roundness(rect, border_width)
radius = roundness * min_dimension / 2
self.assertLessEqual(math.sqrt(2) * (radius - border_width), radius)
def test_border_roundness_preserves_stock_geometry(self):
rect = SimpleNamespace(width=2160, height=1080)
self.assertAlmostEqual(get_border_roundness(rect, 30), 0.12)
def test_lead_indicator_enabled_by_default(self): def test_lead_indicator_enabled_by_default(self):
self.assertTrue(lead_indicator_enabled(FakeParams())) self.assertTrue(lead_indicator_enabled(FakeParams()))
+1 -1
View File
@@ -24,7 +24,7 @@ from openpilot.starpilot.common.starpilot_utilities import delete_file
from openpilot.starpilot.common.starpilot_variables import MODELS_PATH from openpilot.starpilot.common.starpilot_variables import MODELS_PATH
from openpilot.system.hardware.usb import chestnut_firmware_ready from openpilot.system.hardware.usb import chestnut_firmware_ready
MANIFEST_CANDIDATES = ("v25",) MANIFEST_CANDIDATES = ("v24",)
MODEL_NAMESPACE_SUFFIX = "3" MODEL_NAMESPACE_SUFFIX = "3"
DEFAULT_MODEL_KEY = "rdf43" DEFAULT_MODEL_KEY = "rdf43"
LOCAL_MODEL_PREFIX = "local-" LOCAL_MODEL_PREFIX = "local-"
@@ -15,12 +15,12 @@ from openpilot.starpilot.assets.model_manager import MANIFEST_CANDIDATES, ModelM
from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT
def test_v25_is_the_only_manifest_candidate(): def test_v24_is_the_only_manifest_candidate():
assert MANIFEST_CANDIDATES == ("v25",) assert MANIFEST_CANDIDATES == ("v24",)
def test_v25_manifest_is_loaded_from_models_checkout(): def test_v24_manifest_is_loaded_from_models_checkout():
assert ModelManager._manifest_paths("v25") == ("Models/model_names_v25.json",) assert ModelManager._manifest_paths("v24") == ("Models/model_names_v24.json",)
def test_resource_sources_prefer_huggingface_then_github(monkeypatch): def test_resource_sources_prefer_huggingface_then_github(monkeypatch):
@@ -33,9 +33,9 @@ def test_resource_sources_prefer_huggingface_then_github(monkeypatch):
def test_huggingface_manifest_has_root_and_manifests_fallbacks(): def test_huggingface_manifest_has_root_and_manifests_fallbacks():
assert ModelManager._hf_manifest_paths("v25") == ( assert ModelManager._hf_manifest_paths("v24") == (
"model_names_v25.json", "model_names_v24.json",
"manifests/model_names_v25.json", "manifests/model_names_v24.json",
) )
@@ -193,6 +193,19 @@ body {
padding-left: var(--padding-lg); padding-left: var(--padding-lg);
} }
.embedded #sidebar,
.embedded #sidebar_shell,
.embedded #sidebarUnderlay {
display: none !important;
}
.embedded #menu_button {
display: none !important;
}
.embedded .content {
margin-left: 0 !important;
padding-left: var(--padding-lg);
}
/* ――― Headings ――― */ /* ――― Headings ――― */
h1, h1,
h2, h2,
@@ -4,8 +4,10 @@ import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1" import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15" import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
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 { 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 { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
@@ -70,12 +72,15 @@ function Root() {
let routes = [ let routes = [
createRoute("bluetooth", "/bluetooth", Bluetooth), createRoute("bluetooth", "/bluetooth", Bluetooth),
createRoute("wheel_controls", "/wheel-controls", WheelControls), createRoute("wheel_controls", "/wheel-controls", WheelControls),
createRoute("doors", "/manage_doors", DoorControl),
createRoute("tsk", "/manage_tsk", TSKManager),
createRoute("device_settings", "/device_settings/:section?", DeviceSettings), createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs), createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing), createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination), createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys), createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home), createRoute("root", "/", Home),
createRoute("classicRoot", "/classic", Home),
createRoute("routes", "/dashcam_routes", RouteRecordings), createRoute("routes", "/dashcam_routes", RouteRecordings),
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings), createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("sentry", "/sentry", SentryMode), createRoute("sentry", "/sentry", SentryMode),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en" id="htmlElement" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<meta name="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-title" content="Galaxy">
<meta name="format-detection" content="telephone=no">
<meta name="theme-color" content="#8b6cc5" />
<link rel="manifest" href="/assets/mobile/manifest.json" crossorigin="use-credentials">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/mobile/css/material.css">
<script type="importmap">
{
"imports": {
"vue": "/assets/vendor/vue/vue.esm-browser.js"
}
}
</script>
<title>Galaxy</title>
</head>
<body>
<div id="galaxy-bg" aria-hidden="true"></div>
<div id="galaxy-app" v-cloak>
<app-shell></app-shell>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
<script type="module" src="/assets/mobile/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,496 @@
export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
async function handle(res) {
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
err.data = data
throw err
}
return data
}
export const api = {
async postAction(endpoint) {
const res = await fetch(endpoint, { method: "POST" })
return handle(res)
},
async getOptions(endpoint) {
const res = await fetch(endpoint)
return handle(res)
},
async getLayout() {
const res = await fetch(LAYOUT_URL, { cache: "no-store" })
const data = await handle(res)
return (data || [])
.map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
.filter((section) => (section.params || []).length > 0)
},
async getParams() {
const res = await fetch("/api/params/all")
return handle(res)
},
async getDefaults() {
const res = await fetch("/api/params/defaults")
return res.ok ? handle(res) : {}
},
async updateParam({ key, value, label }) {
const body = { key, value }
if (label) body.label = label
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getFlmWorkspace() {
const res = await fetch("/api/flm/workspace", { cache: "no-store" })
return res.ok ? handle(res) : null
},
async getFavoritesSlots() {
const res = await fetch("/api/favorites/slots", { cache: "no-store" })
return handle(res)
},
async saveFavoritesSlots(slots) {
const res = await fetch("/api/favorites/slots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slots }),
})
return handle(res)
},
async activateFavoriteAction(key) {
const res = await fetch("/api/favorites/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
})
return handle(res)
},
async getDeviceStatus() {
const res = await fetch("/api/device/status")
return res.ok ? handle(res) : null
},
async getStats() {
const res = await fetch("/api/stats")
return res.ok ? handle(res) : null
},
async getRoutesStream({ onProgress, onRoutes, signal } = {}) {
const res = await fetch("/api/routes", { signal })
if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() || ""
for (const event of events) {
const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:"))
if (!lines.length) continue
try {
const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n"))
if (Number.isFinite(payload.progress)) onProgress?.(payload.progress)
onRoutes?.(Array.isArray(payload.routes) ? payload.routes : [])
} catch (e) { }
}
}
},
async getRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
return handle(res)
},
async deleteRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
return handle(res)
},
async renameRoute(oldName, newName) {
const res = await fetch("/api/routes/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: oldName, new: newName }),
})
return handle(res)
},
async resetRouteName(name) {
const res = await fetch("/api/routes/reset_name", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
})
return handle(res)
},
async setRoutePreserved(name, preserved) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" })
return handle(res)
},
async deleteAllRoutes(includePreserved) {
const res = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" })
return handle(res)
},
async getRouteLogs(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/logs`)
return handle(res)
},
async getScreenRecordings() {
const res = await fetch("/api/screen_recordings/list")
return handle(res)
},
async deleteScreenRecording(filename) {
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
return handle(res)
},
async deleteAllScreenRecordings() {
const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" })
return handle(res)
},
async renameScreenRecording(oldName, newName) {
const res = await fetch("/api/screen_recordings/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: oldName, new: newName }),
})
return handle(res)
},
async getErrorLogs() {
const res = await fetch("/api/error_logs", { headers: { Accept: "application/json" } })
return handle(res)
},
async getErrorLog(filename) {
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`)
return res.text()
},
async deleteErrorLog(filename) {
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`, { method: "DELETE" })
return res.ok
},
async deleteAllErrorLogs() {
const res = await fetch("/api/error_logs/delete_all", { method: "DELETE" })
return res.ok
},
async getTmuxLogs() {
const res = await fetch("/api/tmux_log/list")
return handle(res)
},
async tmuxCapture() {
const res = await fetch("/api/tmux_log/capture", { method: "POST" })
return res.ok
},
async tmuxSnapshot() {
const res = await fetch("/api/tmux_log/snapshot")
return handle(res)
},
async deleteTmuxLog(filename) {
const res = await fetch(`/api/tmux_log/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
return res.ok
},
async deleteAllTmuxLogs() {
const res = await fetch("/api/tmux_log/delete_all", { method: "DELETE" })
return res.ok
},
async renameTmuxLog(oldName, newName) {
const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
return res.ok
},
async runTroubleshoot() {
const res = await fetch("/api/troubleshoot", { method: "POST" })
return handle(res)
},
async getTroubleshoot() {
const res = await fetch("/api/troubleshoot")
return res.ok ? handle(res) : null
},
async resetTroubleshoot() {
const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
return res.ok
},
async getWheelControlsStatus() {
const res = await fetch("/api/wheel-controls/status", { cache: "no-store" })
return handle(res)
},
async wheelControlsOp(operation, body = {}) {
const res = await fetch(`/api/wheel-controls/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getBluetoothStatus() {
const res = await fetch("/api/bluetooth/status")
return handle(res)
},
async bluetoothOp(operation, body = {}) {
const res = await fetch(`/api/bluetooth/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async carFeaturesCheck(tool = "") {
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
const res = await fetch(`/api/car_features_check${query}`)
return res.ok ? handle(res) : null
},
async lateralManeuvers(action) {
const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
async lateralManeuversStatus() {
const res = await fetch("/api/lateral_maneuvers/status")
return handle(res)
},
async longitudinalManeuvers(action) {
const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
async longitudinalManeuversStatus() {
const res = await fetch("/api/longitudinal_maneuvers/status")
return handle(res)
},
async getMapsStatus() {
const res = await fetch("/api/maps/status")
return handle(res)
},
async getMapsCatalog() {
const res = await fetch("/api/maps/catalog")
return handle(res)
},
async mapsOp(operation, body = {}) {
const res = await fetch(`/api/maps/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getNavigation() {
const res = await fetch("/api/navigation")
return handle(res)
},
async setNavigation(body) {
const res = await fetch("/api/navigation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getNavigationKeys() {
const res = await fetch("/api/navigation_key")
return handle(res)
},
async setNavigationKey(body) {
const res = await fetch("/api/navigation_key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async navigationFavorite(body) {
const res = await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async backupToggles() {
const res = await fetch("/api/toggles/backup", { method: "POST" })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message || "Failed to create toggle backup.")
}
return res.blob()
},
async restoreToggles(data) {
const res = await fetch("/api/toggles/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
return handle(res)
},
async resetTogglesDefault() {
const res = await fetch("/api/toggles/reset_default", { method: "POST" })
return handle(res)
},
async getUpdateBranches() {
const res = await fetch("/api/update/branches")
return handle(res)
},
async getUpdateBranch() {
const res = await fetch("/api/update/branch")
return handle(res)
},
async setUpdateBranch(branch) {
const res = await fetch("/api/update/branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ branch }),
})
return handle(res)
},
async updateFast() {
const res = await fetch("/api/update/fast", { method: "POST" })
return handle(res)
},
async getUpdateFastStatus() {
const res = await fetch("/api/update/fast/status")
return handle(res)
},
async updateRecover() {
const res = await fetch("/api/update/recover", { method: "POST" })
return handle(res)
},
async updateRollback() {
const res = await fetch("/api/update/rollback", { method: "POST" })
return handle(res)
},
async factoryReset() {
const res = await fetch("/api/update/factory_reset", { method: "POST" })
return handle(res)
},
async getAgnosStatus() {
const res = await fetch("/api/update/agnos_status")
return res.ok ? handle(res) : null
},
async getVasmConfig() {
const res = await fetch("/api/v_asm/config")
return handle(res)
},
async setVasmConfig(body) {
const res = await fetch("/api/v_asm/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async vasmSnapshot() {
const res = await fetch("/api/v_asm/snapshot")
return res.ok ? handle(res) : null
},
async getPipConfig() {
const res = await fetch("/api/pip_preview/config")
return handle(res)
},
async setPipConfig(body) {
const res = await fetch("/api/pip_preview/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async pipSnapshot() {
const res = await fetch("/api/pip_preview/snapshot")
return res.ok ? handle(res) : null
},
}
export function showSnackbar(message, level = "info") {
const wrapper = document.getElementById("snackbar_wrapper")
if (!wrapper) return
for (const el of Array.from(wrapper.children)) {
el.classList.remove("show")
el.remove()
}
const el = document.createElement("div")
el.className = "snackbar show"
el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)"
el.style.borderRadius = "var(--border-radius-base, 5px)"
el.style.color = "var(--text-color, #fff)"
el.style.margin = "0 auto var(--margin-base, 1rem)"
el.style.padding = "var(--padding-base, 1rem)"
el.style.textAlign = "center"
el.textContent = message
wrapper.appendChild(el)
setTimeout(() => {
el.classList.remove("show")
setTimeout(() => el.remove(), 500)
}, 2400)
}
@@ -0,0 +1,85 @@
import { createApp, h } from "vue"
import { AppShell } from "./components/AppShell.js"
import { Home } from "./views/Home.js"
import { Settings } from "./views/Settings.js"
import { Tools } from "./views/Tools.js"
import { Recordings } from "./views/Recordings.js"
import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js"
import { Vehicle } from "./views/Vehicle.js"
import { SystemTools } from "./views/SystemTools.js"
import { ToolEmbed } from "./views/ToolEmbed.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
window.__galaxyVue = { createApp, h }
window.addEventListener("message", (event) => {
const data = event?.data
if (!data || data.source !== "galaxy-embed" || typeof data.path !== "string") return
const current = store.params.src || ""
const target = data.path
if (target === current || target === "/" + current) return
navigate("/embed?src=" + encodeURIComponent(target))
})
const VIEWS = {
"/": Home,
"/settings": Settings,
"/tools": Tools,
"/recordings": Recordings,
"/logs": Logs,
"/tuning": Tuning,
"/navigation": Navigation,
"/vehicle": Vehicle,
"/system": SystemTools,
"/embed": ToolEmbed,
}
function resolveView(path) {
if (path === "/embed" || path.startsWith("/embed/")) return ToolEmbed
for (const [root, view] of Object.entries(VIEWS)) {
if (path === root || (root !== "/" && path.startsWith(root + "/"))) return view
}
if (path === "/") return Home
return ToolEmbed
}
const app = createApp({
name: "GalaxyApp",
errorCaptured(err) {
console.error("[galaxy-ui]", err)
showSnackbar("Something went wrong: " + (err?.message || err), "error")
return false
},
computed: {
View() {
return resolveView(store.route)
},
},
render() {
return h(AppShell, null, {
default: () => h(this.View),
})
},
})
app.mount("#galaxy-app")
initRouter()
;(() => {
const bg = document.getElementById("galaxy-bg")
if (!bg) return
for (let i = 0; i < 14; i++) {
const s = document.createElement("i")
s.className = "galaxy-hero"
s.style.left = (Math.random() * 100).toFixed(2) + "%"
s.style.top = (Math.random() * 100).toFixed(2) + "%"
s.style.animationDelay = (Math.random() * 4).toFixed(2) + "s"
const size = Math.random() > 0.6 ? 3 : 2
s.style.width = s.style.height = size + "px"
bg.appendChild(s)
}
})()
@@ -0,0 +1,168 @@
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
import { api } from "../api.js"
import { usePolling } from "../composables.js"
const NAV = {
recordings: [
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
],
tools: [
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" },
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
],
}
const BOTTOM_NAV = [
{ name: "Home", link: "/", icon: "bi-house-fill" },
{ name: "Settings", link: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", link: "/tools", icon: "bi-tools" },
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
]
export const AppShell = {
name: "AppShell",
data() {
return { store, BOTTOM_NAV, NAV }
},
computed: {
online() { return store.online },
statusLabel() { return store.online ? store.deviceStatus : "Offline" },
isLight() { return store.theme === "light" },
drawerOpen: {
get() { return store.drawerOpen },
set(v) { store.drawerOpen = v },
},
activePath() { return store.route },
search: {
get() { return store.search },
set(v) { store.search = v },
},
},
watch: {
"store.search"(q) {
if (q && store.route !== "/settings" && !store.route.startsWith("/settings/")) {
navigate("/settings")
}
},
},
methods: {
closeDrawer() { store.drawerOpen = false },
back() { goBack() },
async refreshStatus() {
try {
const payload = await api.getDeviceStatus()
if (!payload) throw new Error("no status")
store.online = true
store.deviceStatus = String(payload.status || "Parked")
} catch (e) {
store.online = false
}
},
clearSearch() {
store.search = ""
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
},
themeToggle() { toggleTheme() },
navTo(link) {
this.closeDrawer()
navigate(toolHref(link))
},
bottomNavTo(item) {
navigate(item.link)
},
isActive(link) {
return this.activePath === link || (link !== "/" && this.activePath.startsWith(link))
},
},
created() {
this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 })
this.statusPoll.start()
},
beforeUnmount() {
this.statusPoll?.destroy()
},
template: `
<div class="gx-app">
<header class="gx-appbar">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" aria-label="Back" @click="back">
<i class="bi bi-arrow-left"></i>
</button>
<div class="gx-appbar__pill">
<button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true">
<i class="bi bi-list"></i>
</button>
<span class="gx-appbar__title">Galaxy</span>
<div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search settings..."
v-model="search" aria-label="Search settings" />
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
<i class="bi bi-x"></i>
</button>
</div>
<div class="gx-appbar__right">
<span class="gx-status-pill">
<span class="gx-status-dot" :class="online ? 'online' : 'offline'"></span>
{{ statusLabel }}
</span>
</div>
</div>
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? 'Switch to dark mode' : 'Switch to light mode'"
:title="isLight ? 'Dark mode' : 'Light mode'" @click="themeToggle">
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
</button>
</header>
<transition name="gx-fade">
<div v-if="store.drawerOpen" class="gx-underlay" @click="closeDrawer"></div>
</transition>
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
<div class="gx-drawer__header">
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<span class="gx-drawer-title">Galaxy</span>
</div>
<div class="gx-nav-section">
<div class="gx-nav-section__title">Main</div>
<a class="gx-nav-item" :class="{ active: isActive('/') }" @click.prevent="navTo('/')">
<i class="bi bi-house-fill"></i><span>Home</span>
</a>
<a class="gx-nav-item" :class="{ active: isActive('/settings') }" @click.prevent="navTo('/settings')">
<i class="bi bi-toggle-on"></i><span>Toggles</span>
</a>
<a class="gx-nav-item" :class="{ active: isActive('/tools') }" @click.prevent="navTo('/tools')">
<i class="bi bi-tools"></i><span>Tools</span>
</a>
</div>
<div v-for="(links, section) in NAV" :key="section" class="gx-nav-section">
<div class="gx-nav-section__title">{{ section }}</div>
<a v-for="link in links" :key="link.link" class="gx-nav-item" @click.prevent="navTo(link.link)">
<i class="bi" :class="link.icon"></i><span>{{ link.name }}</span>
</a>
</div>
</aside>
<main class="gx-content">
<slot />
</main>
<nav class="liquid-glass-nav">
<button v-for="item in BOTTOM_NAV" :key="item.link" type="button"
class="nav-item" :class="{ active: isActive(item.link) }"
@click="bottomNavTo(item)">
<i class="bi" :class="item.icon"></i>
<span>{{ item.name }}</span>
</button>
</nav>
</div>
`,
}
@@ -0,0 +1,134 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
function address(device) { return String(device.address || "").toUpperCase() }
export const BluetoothPanel = {
name: "BluetoothPanel",
data() {
return {
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
offroad: false, selectedAudio: "", pairingAddress: "", devices: [], prompt: null, pairValue: "", error: "",
}
},
created() { this.poll = usePolling(() => this.refresh(), { interval: 2000 }); this.poll.start() },
beforeUnmount() { this.poll?.destroy() },
computed: {
known() { return this.devices.filter((d) => d.paired || d.trusted || d.connected) },
availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) },
},
methods: {
async refresh() {
try {
const p = await api.getBluetoothStatus()
this.available = !!p.available
this.enabled = !!p.enabled
this.powered = !!p.powered
this.discovering = !!p.discovering
this.offroad = !!p.offroad
this.selectedAudio = String(p.selected_audio || "")
this.pairingAddress = String(p.pairing_address || "")
this.devices = Array.isArray(p.devices) ? p.devices : []
this.prompt = p.prompt || null
this.error = p.error || ""
} catch (e) {
this.available = false
this.error = e?.message || "Bluetooth service unavailable"
} finally {
this.loading = false
}
},
async request(operation, body = {}) {
if (this.busy) return
this.busy = operation
try {
await api.bluetoothOp(operation, body)
this.error = ""
await this.refresh()
} catch (e) {
this.error = e?.message || "Bluetooth operation failed"
} finally {
this.busy = ""
}
},
pair(d) { this.request("pair", { address: d.address }) },
connect(d) { this.request(d.connected ? "disconnect" : "connect", { address: d.address }) },
forget(d) { this.request("forget", { address: d.address }) },
audio(d) { const isSel = this.selectedAudio.toUpperCase() === address(d); this.request("select_audio", { address: isSel ? "" : d.address }) },
testAudio(d) { this.request("test_audio", { address: d.address }) },
respondPairing(accepted) {
const prompt = this.prompt
if (!prompt || this.busy === "pairing_response") return
if (accepted && (prompt.kind === "pin" || prompt.kind === "passkey") && !this.pairValue.trim()) {
this.error = "Enter the value to continue pairing."
return
}
this.request("pairing_response", { prompt_id: prompt.id, accepted, value: this.pairValue.trim() })
},
isPairing(d) { return !!this.pairingAddress && this.pairingAddress.toUpperCase() === address(d) },
statusOf(d) {
if (this.isPairing(d)) return "Pairing…"
if (d.connected) {
const audioSel = this.selectedAudio.toUpperCase() === address(d)
return audioSel ? "Connected · Audio output" : "Connected"
}
return d.paired ? "Saved" : "Ready to pair"
},
offroadDisabled() { return !this.offroad || !!this.busy },
needsPairValue() { return this.prompt && (this.prompt.kind === "pin" || this.prompt.kind === "passkey") },
},
template: `
<div>
<div style="padding: var(--sp-3);">
<div style="display:flex; align-items:center; gap:12px; justify-content:space-between;">
<span>Bluetooth {{ enabled ? 'On' : 'Off' }}</span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || offroadDisabled()" @click="request('power', { enabled: !enabled })">{{ enabled ? 'Turn Off' : 'Turn On' }}</button>
</div>
<p v-if="!offroad" style="color:var(--text-muted);">Scanning, pairing, and forgetting devices are available offroad only.</p>
<p v-if="error" style="color:var(--error);">{{ error }}</p>
<div v-if="prompt" class="gx-card" style="margin:12px 0; background:var(--surface-variant);">
<div class="gx-section__header"><i class="bi bi-shield-check"></i><span class="gx-section__title">Pairing request · {{ prompt.name }}</span></div>
<div style="padding: var(--sp-3);">
<p style="color:var(--text-muted);">{{ prompt.kind === 'confirmation' ? 'Confirm the pairing request.' : prompt.kind === 'authorization' ? 'Allow this device to connect?' : prompt.kind === 'pin' ? 'Enter the PIN supplied by the device.' : 'Enter the device passkey.' }}</p>
<input v-if="needsPairValue" v-model="pairValue" class="gx-field" style="width:100%;" inputmode="numeric" placeholder="Value" />
<div v-if="!prompt.display_only" style="display:flex; gap:8px; margin-top:8px;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy==='pairing_response'" @click="respondPairing(false)">Cancel</button>
<button type="button" class="gx-btn" :disabled="busy==='pairing_response'" @click="respondPairing(true)">Allow</button>
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin:12px 0;">
<button type="button" class="gx-btn" :disabled="!offroad || !enabled || offroadDisabled()" @click="request(discovering ? 'stop_scan' : 'scan')">{{ discovering ? 'Searching…' : 'Search for Devices' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="refresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<h4 style="margin:12px 0 8px;">My Devices</h4>
<div v-if="!known.length" class="gx-empty" style="padding: var(--sp-2) 0;">No saved devices yet.</div>
<div v-for="d in known" :key="d.address" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }} <span v-if="d.connected" class="gx-chip gx-chip--dev">Connected</span></span>
<span class="gx-row__desc">{{ d.audio && d.controller ? 'Audio · Controller' : d.audio ? 'Audio' : d.controller ? 'Controller' : 'Bluetooth' }} · {{ statusOf(d) }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button v-if="d.paired || d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="connect(d)">{{ d.connected ? 'Disconnect' : 'Connect' }}</button>
<button v-if="d.audio" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="audio(d)">{{ selectedAudio.toUpperCase() === address(d) ? 'Stop Using for Audio' : 'Use for Audio' }}</button>
<button v-if="d.audio && d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="offroadDisabled()" @click="testAudio(d)">Test Audio</button>
<button v-if="d.paired" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="offroadDisabled()" @click="forget(d)"><i class="bi bi-trash"></i></button>
</div>
</div>
<h4 style="margin:12px 0 8px;">Available Devices</h4>
<div v-if="!availableDevices.length" class="gx-empty" style="padding: var(--sp-2) 0;">{{ discovering ? 'Searching for nearby devices…' : 'No nearby devices found.' }}</div>
<div v-for="d in availableDevices" :key="d.address" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }}</span>
<span class="gx-row__desc">{{ statusOf(d) }}</span>
</div>
<button type="button" class="gx-btn" :disabled="!offroad || !!busy || isPairing(d)" @click="pair(d)">{{ isPairing(d) ? 'Pairing…' : 'Pair' }}</button>
</div>
</div>
</div>
`,
}
@@ -0,0 +1,25 @@
import { navigate } from "../store.js"
export const DevModeBanner = {
name: "DevModeBanner",
props: {
hiddenCount: { type: Number, default: 0 },
devModeOn: { type: Boolean, default: false },
},
computed: {
visible() { return !this.devModeOn && this.hiddenCount > 0 },
},
methods: {
unlock() { navigate("/settings/developer") },
},
template: `
<div v-if="visible" class="gx-alert gx-alert--warn" role="status">
<i class="bi bi-shield-lock gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>{{ hiddenCount }} advanced setting{{ hiddenCount !== 1 ? "s" : "" }} hidden.</strong>
<span>Advanced features are tucked away until you enable Developer Mode.</span>
</div>
<button type="button" class="gx-btn gx-btn--tonal" @click="unlock">Enable Developer Mode</button>
</div>
`,
}
@@ -0,0 +1,184 @@
import { api, showSnackbar } from "../api.js"
const FAVORITE_COUNT = 3
const ACTION_PREFIX = "__starpilot_favorite_action__:"
function sortOptions(options) {
return (options || []).slice().sort((a, b) =>
String(a?.label || a?.key || "").localeCompare(String(b?.label || b?.key || ""), undefined, { numeric: true, sensitivity: "base" })
)
}
function defaultSlots() {
return [0, 1, 2].map(() => ({ enabled: false, show_onroad: false, key: null, label: "" }))
}
function normalizeSlots(slots) {
const base = defaultSlots()
if (!Array.isArray(slots)) return base
slots.slice(0, FAVORITE_COUNT).forEach((slot, index) => {
if (!slot || typeof slot !== "object") return
const key = slot.key ? String(slot.key) : null
base[index] = {
enabled: !!slot.enabled,
show_onroad: !!slot.show_onroad,
key,
label: key ? String(slot.label || key) : "",
}
})
return base
}
export const FavoritesEditor = {
name: "FavoritesEditor",
data() {
return {
loading: true,
saving: false,
slots: [],
options: [],
values: {},
filters: ["", "", ""],
}
},
computed: {
optionByKey() { return new Map(this.options.map((o) => [o.key, o])) },
quickFavorites() {
return this.slots
.map((slot, index) => {
const opt = this.optionByKey.get(slot.key || "")
return { index, slot, opt, checked: !!(slot.key && opt && !!this.values[slot.key]) }
})
.filter((f) => f.slot.enabled && f.slot.key && f.opt)
},
},
methods: {
normalizeSlots,
filteredOptions(index) {
const q = (this.filters[index] || "").toLowerCase()
return this.options.filter((o) =>
!q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q))
)
},
isActionSlot(slot) {
const opt = this.optionByKey.get(slot.key || "")
return String(slot.key || "").startsWith(ACTION_PREFIX) || !!opt?.action
},
async load() {
this.loading = true
try {
const data = await api.getFavoritesSlots()
this.options = sortOptions(data?.options)
this.slots = normalizeSlots(data?.slots)
this.values = { ...this.values, ...(data?.values || {}) }
} catch (e) {
showSnackbar("Failed to load favorite slots.", "error")
} finally {
this.loading = false
}
},
async saveSlots() {
if (this.saving) return
this.saving = true
try {
const data = await api.saveFavoritesSlots(this.slots)
this.slots = normalizeSlots(data?.slots)
if (Array.isArray(data?.options)) this.options = sortOptions(data.options)
if (data?.values) this.values = { ...this.values, ...data.values }
showSnackbar(data?.message || "Favorite slots saved.")
} catch (e) {
showSnackbar(e?.message || "Failed to save favorite slots.", "error")
} finally {
this.saving = false
}
},
updateSlot(index, patch) {
const slots = this.slots.slice()
slots[index] = { ...slots[index], ...patch }
if (!slots[index].key) {
slots[index].label = ""
} else {
slots[index].label = this.optionByKey.get(slots[index].key)?.label || slots[index].key
}
this.slots = slots
this.saveSlots()
},
async toggleValue(key, checked) {
const previous = this.values[key]
this.values = { ...this.values, [key]: checked }
try {
const data = await api.updateParam({ key, value: checked })
if (data?.updated && typeof data.updated === "object") this.values = { ...this.values, ...data.updated }
showSnackbar(data?.message || `Parameter '${key}' updated.`)
} catch (e) {
this.values = { ...this.values, [key]: previous }
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
}
},
async runAction(key) {
try {
const data = await api.activateFavoriteAction(key)
showSnackbar(data?.message || "Favorite action sent.")
} catch (e) {
showSnackbar(e?.message || "Failed to send favorite action.", "error")
}
},
},
async mounted() { await this.load() },
template: `
<div class="favorites-editor" style="display:grid; gap:var(--sp-3);">
<div v-if="loading" class="gx-loading">Loading favorite slots...</div>
<template v-else>
<div v-if="quickFavorites.length" style="display:grid; gap:8px; grid-template-columns:repeat(auto-fit,minmax(180px,1fr));">
<div v-for="f in quickFavorites" :key="f.slot.key"
style="display:flex; flex-direction:column; gap:4px; padding:var(--sp-2) var(--sp-3); border:1px solid var(--outline-variant); border-radius:var(--radius-md);">
<small style="color:var(--text-muted);">Favorite #{{ f.index + 1 }}</small>
<strong>{{ f.opt.label || f.slot.key }}</strong>
<span style="color:var(--text-muted); font-size:var(--fs-sm);">{{ f.opt.section || '' }}</span>
<button v-if="isActionSlot(f.slot)" type="button" class="gx-btn" :disabled="saving" @click.prevent="runAction(f.slot.key)">
Press
</button>
<label v-else class="gx-switch" style="align-self:flex-start;">
<input type="checkbox" :checked="f.checked" :disabled="saving" @change="toggleValue(f.slot.key, $event.target.checked)" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
</div>
<div v-for="(slot, index) in slots" :key="index" class="gx-card">
<div class="gx-section__header">
<span class="gx-section__title">Favorite #{{ index + 1 }}</span>
<label class="gx-switch">
<input type="checkbox" :checked="slot.enabled" :disabled="saving" @change="updateSlot(index, { enabled: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<label style="display:grid; gap:4px;">
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Search</span>
<input class="gx-field" type="search" :value="filters[index] || ''" :disabled="saving" placeholder="Search toggles..." @input="filters = filters.map((f,i)=> i===index ? $event.target.value : f)" />
</label>
<label style="display:grid; gap:4px;">
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Toggle</span>
<select class="gx-field" :value="slot.key || ''" :disabled="saving" @change="updateSlot(index, { key: $event.target.value || null })">
<option value="">Select a toggle...</option>
<option v-for="opt in filteredOptions(index)" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</label>
<div style="display:flex; align-items:center; gap:8px;">
<span style="flex:1; font-size:var(--fs-sm);">On-Road Button (C4: tap invisible third)</span>
<label class="gx-switch">
<input type="checkbox" :checked="slot.show_onroad" :disabled="saving || !slot.enabled || !slot.key" @change="updateSlot(index, { show_onroad: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
</div>
</div>
</template>
</div>
`,
}
@@ -0,0 +1,82 @@
// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds,
// SystemTools toggles, Logs troubleshoot) renders through this component so the
// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and
// optional navigation forwarding back to the mobile app.
export const GalaxyEmbed = {
name: "GalaxyEmbed",
props: {
src: { type: String, required: true },
title: { type: String, default: "Tool" },
// When true, injects a bridge that forwards the classic page's internal
// navigation to the mobile app (which opens it as a proper ToolEmbed page).
forwardNav: { type: Boolean, default: false },
},
data() {
return {
embedStyle: `
#sidebar, #sidebar_shell, #sidebarUnderlay { display: none !important; }
#menu_button { display: none !important; }
.content { margin-left: 0 !important; }
body { padding-left: 0 !important; }
`,
}
},
computed: {
frameSrc() {
const base = this.src
return base + (base.includes("?") ? "&" : "?") + "embedded=1"
},
},
methods: {
injectEmbedStyles() {
const frame = this.$refs.frame
if (!frame) return
try {
const doc = frame.contentDocument || frame.contentWindow?.document
if (!doc || !doc.head) return
let style = doc.getElementById("gx-embed-hide-sidebar")
if (!style) {
style = doc.createElement("style")
style.id = "gx-embed-hide-sidebar"
doc.head.appendChild(style)
}
style.textContent = this.embedStyle
if (!this.forwardNav) return
let bridge = doc.getElementById("gx-embed-nav-bridge")
if (!bridge) {
bridge = doc.createElement("script")
bridge.id = "gx-embed-nav-bridge"
bridge.textContent = `(() => {
const post = () => {
if (window.self === window.top) return
const params = new URLSearchParams(window.location.search)
params.delete("embedded")
const qs = params.toString()
window.parent.postMessage({ source: "galaxy-embed", path: window.location.pathname + (qs ? "?" + qs : "") }, "*")
}
const patch = (type) => {
const orig = history[type]
history[type] = function () { const r = orig.apply(this, arguments); post(); return r }
}
patch("pushState")
patch("replaceState")
window.addEventListener("popstate", post)
})()`
doc.head.appendChild(bridge)
}
} catch (e) {
}
},
},
mounted() {
this.$refs.frame?.addEventListener("load", () => this.injectEmbedStyles())
},
template: `
<div class="gx-embed">
<iframe ref="frame" :src="frameSrc" class="gx-embed__frame" frameborder="0"
allow="clipboard-read; clipboard-write" :title="title"></iframe>
</div>
`,
}
@@ -0,0 +1,63 @@
export const GalaxyModal = {
name: "GalaxyModal",
props: {
modelValue: { type: Boolean, default: false },
title: { type: String, default: "Are you sure?" },
message: { type: String, default: "" },
confirmLabel: { type: String, default: "Confirm" },
cancelLabel: { type: String, default: "Cancel" },
danger: { type: Boolean, default: false },
sheet: { type: Boolean, default: true },
},
emits: ["update:modelValue", "confirm", "cancel"],
methods: {
close() { this.$emit("update:modelValue", false) },
cancel() { this.close(); this.$emit("cancel") },
confirm() { this.$emit("confirm"); this.close() },
},
template: `
<transition name="gx-fade">
<div v-if="modelValue" class="gx-scrim" @click.self="cancel">
<transition name="gx-slide" appear>
<div class="gx-sheet" role="dialog" :aria-label="title">
<h3 class="gx-sheet__title">{{ title }}</h3>
<p v-if="message" style="color: var(--text-muted); line-height: 1.5;">{{ message }}</p>
<div class="gx-dialog__actions">
<button type="button" class="gx-btn gx-btn--text" @click="cancel">{{ cancelLabel }}</button>
<button type="button" class="gx-btn" :style="danger ? 'background: var(--error); color: var(--on-error);' : ''" @click="confirm">{{ confirmLabel }}</button>
</div>
</div>
</transition>
</div>
</transition>
`,
}
export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger = false } = {}) {
return new Promise((resolve) => {
const host = document.createElement("div")
document.body.appendChild(host)
const { createApp, h } = window.__galaxyVue
let instance
const app = createApp({
render() {
return h(GalaxyModal, {
modelValue: true,
title,
message,
confirmLabel,
danger,
"onUpdate:modelValue": (v) => { if (!v) teardown() },
onConfirm: () => { teardown(); resolve(true) },
onCancel: () => { teardown(); resolve(false) },
})
},
})
const teardown = () => {
app.unmount()
host.remove()
resolve(false)
}
instance = app.mount(host)
})
}
@@ -0,0 +1,25 @@
export const GalaxySection = {
name: "GalaxySection",
props: {
title: { type: String, required: true },
icon: { type: String, default: "bi-toggles" },
count: { type: [Number, String], default: "" },
defaultOpen: { type: Boolean, default: true },
},
data() { return { open: this.defaultOpen } },
template: `
<section class="gx-card">
<div class="gx-section__header" role="button" @click="open = !open">
<i class="bi" :class="icon"></i>
<span class="gx-section__title">{{ title }}</span>
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
<i class="bi bi-chevron-down gx-chevron" :class="{ open }"></i>
</div>
<transition name="gx-collapse">
<div v-show="open" class="gx-section__body">
<slot />
</div>
</transition>
</section>
`,
}
@@ -0,0 +1,226 @@
import { api, showSnackbar } from "../api.js"
import {
coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
stepPrecision,
} from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js"
export const GalaxyToggleCard = {
name: "GalaxyToggleCard",
components: { FavoritesEditor },
props: {
param: { type: Object, required: true },
value: { default: undefined },
locked: { type: Boolean, default: false },
manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false },
},
emits: ["change", "manage"],
data() {
return {
updating: false,
endpointOptions: null,
optionsLoaded: false,
endpointLoading: false,
preview: undefined,
interacting: false,
}
},
computed: {
bounds() { return numericBounds(this.param, {}) },
precision() { return stepPrecision(this.bounds.step, this.param.precision) },
epsilon() { return numericEpsilon(this.precision) },
isSlider() { return this.isNumeric },
isNumeric() { return this.param.ui_type === "numeric" },
isReadout() { return this.param.ui_type === "readout" },
isGroup() { return this.param.ui_type === "group" },
currentValue() { return this.preview !== undefined ? this.preview : this.value },
displayValue() {
if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
if (this.isReadout) return formatReadoutValue(this.param, this.value)
return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
sliderDisplay() {
return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
isColor() { return this.param.ui_type === "color" },
isAction() { return this.param.ui_type === "action" },
isFavorites() { return this.param.ui_type === "favorites" },
isText() { return this.param.ui_type === "text" },
isSelect() { return this.param.ui_type === "dropdown" },
isSwitch() { return !this.isNumeric && !this.isColor && !this.isAction && !this.isFavorites && !this.isGroup && !this.isReadout && !this.isSelect && !this.isText },
selectOptions() {
return this.param.options || this.endpointOptions || []
},
optionsLoading() {
return Boolean(this.param.options_endpoint) && this.endpointLoading
},
},
methods: {
normalizeHexColor,
getColorDefault,
coerce(v) { return coerceValueByType(v, this.param.data_type) },
labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
async commit(nextValue) {
const prev = this.value
const label = this.lastLabel || ""
this.$emit("change", { key: this.param.key, value: nextValue })
this.updating = true
try {
const data = await api.updateParam({ key: this.param.key, value: nextValue, label })
const updated = data?.updated && typeof data.updated === "object" ? data.updated : {}
if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) {
this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated })
}
showSnackbar(data?.message || `Parameter '${this.param.key}' updated.`)
} catch (err) {
this.rollback(prev)
showSnackbar(err?.message || "Network error — is the device reachable?", "error")
} finally {
this.updating = false
}
},
onSwitch(e) {
if (!this.locked) this.commit(!!e.target.checked)
else e.target.checked = !!this.value
},
onSelect(e) {
if (this.locked) { e.target.value = String(this.value ?? "") ; return }
this.lastLabel = e.target.options?.[e.target.selectedIndex]?.textContent || ""
this.commit(this.coerce(e.target.value))
},
onText(e) {
if (!this.locked) this.commit(this.coerce(e.target.value))
},
onColor(e) {
if (this.locked) return
this.commit(normalizeHexColor(e.target.value) || getColorDefault(this.param))
},
beginInteract() { this.interacting = true },
flushSlider(rawValue) {
const next = snapNumericToBoundsAndStep(rawValue, this.bounds, this.precision)
this.preview = undefined
if (next === null) return
const current = this.snap(this.value)
if (Math.abs(next - current) <= this.epsilon) return
this.commit(next)
},
onSliderInput(e) {
this.beginInteract()
this.preview = Number(e.target.value)
},
onSliderCommit(e) {
this.interacting = false
this.flushSlider(e.target.value)
},
onSliderBlur(e) {
if (this.interacting) this.onSliderCommit(e)
},
snap(raw) {
return snapNumericToBoundsAndStep(raw, this.bounds, this.precision)
},
async resetToDefault() {
const defaults = await api.getDefaults()
const stockKey = `${this.param.key}Stock`
const stock = defaults?.[stockKey]
const raw = stock !== undefined && stock !== null ? stock : defaults?.[this.param.key]
const next = this.snap(raw)
if (next === null) { showSnackbar("No default value available for this setting.", "error"); return }
if (Math.abs(next - (this.snap(this.value) ?? 0)) <= this.epsilon) return
this.commit(next)
},
resetColor() {
if (normalizeHexColor(this.value) === "") return
this.commit("stock")
},
runAction() {
if (this.locked || this.updating) return
this.updating = true
api.postAction(String(this.param.action_endpoint || ""))
.then((data) => {
if (!data?.error) {
showSnackbar(data?.message || `${this.param.label || this.param.key} completed.`)
if (data?.updated && typeof data.updated === "object") this.$emit("change", data.updated)
} else {
showSnackbar(data.error, "error")
}
})
.catch(() => showSnackbar(`${this.param.label || this.param.key} failed.`, "error"))
.finally(() => { this.updating = false })
},
loadEndpointOptions() {
if (!this.param.options_endpoint || this.optionsLoaded) return
this.optionsLoaded = true
this.endpointLoading = true
api.getOptions(this.param.options_endpoint)
.then((opts) => { this.endpointOptions = opts })
.catch(() => { this.endpointOptions = [] })
.finally(() => { this.endpointLoading = false })
},
},
mounted() {
if (this.param.options_endpoint) this.loadEndpointOptions()
},
template: `
<div>
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
<div class="gx-row__info">
<span class="gx-row__label">{{ param.label }}
<span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</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>
<label v-if="isSwitch" class="gx-switch">
<input type="checkbox" :checked="!!value" :disabled="locked || updating" @change="onSwitch" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
<div v-else-if="isFavorites" style="width:100%;">
<FavoritesEditor />
</div>
<div v-else-if="isSlider" class="gx-slider-row">
<span class="gx-row__value" style="min-width:64px; text-align:right;">{{ sliderDisplay }}</span>
<input type="range" class="gx-slider" :min="bounds.min" :max="bounds.max" :step="bounds.step"
:value="currentValue" :disabled="locked || updating"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
</div>
<select v-else-if="isSelect" class="gx-field" :disabled="locked || updating" :value="String(value ?? '')" @change="onSelect">
<option v-if="optionsLoading" value="">Loading...</option>
<option v-else-if="!selectOptions.length" value="">No options available</option>
<option v-for="opt in selectOptions" :key="String(opt.value)" :value="String(opt.value)">{{ opt.label }}</option>
</select>
<input v-else-if="isText" class="gx-field" :type="param.input_type || 'text'" :value="value ?? ''"
:placeholder="param.placeholder || ''" :disabled="locked || updating" @change="onText" />
<div v-else-if="isColor" style="display:flex; align-items:center; gap:8px;">
<span class="gx-row__value">{{ displayValue }}</span>
<input type="color" class="gx-color" :value="normalizeHexColor(value) || getColorDefault(param)"
:disabled="locked || updating" @change="onColor" />
<button class="gx-slider-reset" :disabled="locked || updating || !normalizeHexColor(value)" @click="resetColor">Stock</button>
</div>
<span v-else-if="isReadout" class="gx-row__value">{{ displayValue }}</span>
<button v-else-if="isAction" class="gx-btn" :disabled="locked || updating" @click="runAction">
{{ updating ? "Working..." : (param.action_label || "Run") }}
</button>
<button v-else-if="isGroup" class="gx-btn gx-btn--tonal" @click="$emit('manage', param.key)">Manage</button>
</div>
<button v-if="manageable" type="button" class="gx-manage-btn" @click="$emit('manage', param.key)">
{{ manageOpen ? "Close" : "Manage" }}
<i class="bi" :class="manageOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
`,
}
@@ -0,0 +1,89 @@
import { usePolling, formatAgeSeconds } from "../composables.js"
import { showSnackbar } from "../api.js"
function safeNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
export const ManeuverCard = {
name: "ManeuverCard",
props: {
title: { type: String, required: true },
icon: { type: String, default: "bi-sign-turn-right" },
intro: { type: String, default: "" },
start: { type: Function, required: true },
stop: { type: Function, required: true },
status: { type: Function, required: true },
interval: { type: Number, default: 3000 },
},
data() {
return { loading: true, busy: false, data: null }
},
created() {
this.poll = usePolling(() => this.refreshStatus(), { interval: this.interval })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
methods: {
formatAgeSeconds,
safeNumber,
async refreshStatus() {
try {
const payload = await this.status()
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : null
this.loading = false
} catch (e) {
this.loading = false
throw e
}
},
async run(action) {
if (this.busy) return
this.busy = true
try {
const fn = action === "start" ? this.start : this.stop
const payload = await fn()
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : this.data
showSnackbar(payload?.message || "Action complete.")
} catch (e) {
showSnackbar(e?.message || "Action failed.", "error")
} finally {
this.busy = false
}
},
},
template: `
<section class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="icon"></i>
<span class="gx-section__title">{{ title }}</span>
</div>
<div style="padding: var(--sp-3);">
<p style="color: var(--text-muted); line-height:1.5;">{{ intro }}</p>
<div style="display:flex; gap:8px; margin: 12px 0;">
<button type="button" class="gx-btn" :disabled="busy" @click="run('start')">Start / Arm</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="busy" @click="run('stop')">Stop</button>
</div>
<div v-if="loading" class="gx-loading">Loading status...</div>
<dl v-else-if="data" class="gx-stat-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div><strong>Mode</strong><span>{{ data.modeEnabled ? 'Yes' : 'No' }}</span></div>
<div><strong>State</strong><span>{{ data.state || 'idle' }}</span></div>
<div><strong>Onroad</strong><span>{{ data.isOnroad ? 'Yes' : 'No' }}</span></div>
<div><strong>Engaged</strong><span>{{ data.isEngaged ? 'Yes' : 'No' }}</span></div>
<div><strong>Phase</strong><span>{{ data.phase || 'n/a' }}</span></div>
<div><strong>Step</strong><span>{{ safeNumber(data.stepIndex, 0) }}/{{ safeNumber(data.stepTotal, 0) }}</span></div>
<div><strong>Run</strong><span>{{ safeNumber(data.runIndex, 0) }}/{{ safeNumber(data.runTotal, 0) }}</span></div>
<div><strong>Updated</strong><span>{{ formatAgeSeconds(data.updatedAgeSec) }}</span></div>
<div style="grid-column:1/-1;"><strong>Current</strong><span>{{ data.maneuver || 'n/a' }}</span></div>
</dl>
<div v-if="data && data.history?.length" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header"><i class="bi bi-list-ol"></i><span class="gx-section__title">Progress Chain</span></div>
<ol style="margin:0; padding: var(--sp-3) var(--sp-4);">
<li v-for="line in [...data.history].reverse()" :key="line">{{ line }}</li>
</ol>
</div>
</div>
</section>
`,
}
@@ -0,0 +1,66 @@
import { api } from "../api.js"
import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
import { SettingTree } from "./SettingTree.js"
import { GalaxySection } from "./GalaxySection.js"
export const ParamSections = {
name: "ParamSections",
components: { SettingTree, GalaxySection },
props: {
sectionNames: { type: Array, required: true },
search: { type: String, default: "" },
},
data() {
return {
layout: [],
values: {},
expanded: {},
loading: true,
error: "",
}
},
computed: {
sections() {
return this.layout
.filter((s) => this.sectionNames.includes(s.name))
.map((s) => ({
...s,
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values) && this.matches(p)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
},
},
methods: {
matches(p) {
if (!this.search) return true
const q = this.search.toLowerCase()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
},
async load() {
try {
const [layout, values] = await Promise.all([api.getLayout(), api.getParams()])
this.layout = layout
this.values = values || {}
} catch (e) {
this.error = e?.message || "Failed to load settings."
} finally {
this.loading = false
}
},
onParamChange(patch) { this.values = applyParamChange(this.values, patch) },
toggleManage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
},
async mounted() { await this.load() },
template: `
<div>
<div v-if="loading" class="gx-loading">Loading configuration...</div>
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
<GalaxySection v-for="s in sections" :key="s.slug" :title="s.name" :icon="s.icon || 'bi-toggles'" :count="s.params.length">
<SettingTree :params="s.params" :parent-key="null" :values="values" :expanded="expanded"
@change="onParamChange" @manage="toggleManage" />
<div v-if="!s.params.length" class="gx-empty">No settings in this section.</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,46 @@
import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
export const SettingTree = {
name: "SettingTree",
components: { GalaxyToggleCard },
props: {
params: { type: Array, required: true },
parentKey: { default: null },
depth: { type: Number, default: 0 },
values: { type: Object, required: true },
expanded: { type: Object, default: () => ({}) },
lockReason: { type: Function, default: () => "" },
},
emits: ["change", "manage"],
computed: {
children() {
return this.params.filter((p) => (p.parent_key || null) === this.parentKey)
},
},
methods: {
enabledForChildren(p) { return isParamEnabledForChildren(p, this.values) },
isParent(p) { return hasChildParams(this.params, p.key) },
isGroup(p) { return isGroupParam(p) },
isExpanded(p) { return !!this.expanded[p.key] },
showChildren(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
manageable(p) { return this.isParent(p) && this.enabledForChildren(p) },
manageOpen(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
},
template: `
<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">
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
<transition name="gx-collapse">
<div v-if="showChildren(p)" class="gx-tree-children">
<SettingTree :params="params" :parent-key="p.key" :depth="depth + 1"
:values="values" :expanded="expanded" :lock-reason="lockReason"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
</transition>
</template>
`,
}
@@ -0,0 +1,165 @@
import { api } from "../api.js"
import { usePolling } from "../composables.js"
const FAVORITE_SLOT_COUNT = 3
export const WheelControls = {
name: "WheelControls",
data() {
return {
loading: true, busy: "", available: false, offroad: false, learning: false,
devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
}
},
created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() },
beforeUnmount() { this.poll?.destroy() },
methods: {
async refresh() {
try {
const p = await api.getWheelControlsStatus()
this.available = !!p.available
this.offroad = !!p.offroad
this.learning = !!p.learning
this.devices = Array.isArray(p.devices) ? p.devices : []
this.mappings = Array.isArray(p.mappings) ? p.mappings : []
this.slots = Array.isArray(p.slots) ? p.slots : []
this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
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.testing = !!p.testing
this.lastTested = p.last_tested && typeof p.last_tested === "object" ? p.last_tested : null
this.speedUnit = String(p.speed_unit || "mph")
this.speedMinimum = Number.isFinite(Number(p.speed_minimum)) ? Number(p.speed_minimum) : 0
this.speedMaximum = Number.isFinite(Number(p.speed_maximum)) ? Number(p.speed_maximum) : 0
this.error = ""
} catch (e) {
this.available = false
this.error = e?.message || "Wheel controls are unavailable"
} finally {
this.loading = false
}
},
async request(operation, body = {}) {
if (this.busy) return
this.busy = operation
try {
await api.wheelControlsOp(operation, body)
this.error = ""
await this.refresh()
} catch (e) {
this.error = e?.message || "Wheel control operation failed"
} finally {
this.busy = ""
}
},
mappingsOf(slot) { return this.mappings.filter((m) => m.slot === slot) },
actionSlotIndex(i) { return FAVORITE_SLOT_COUNT + i },
learn(slot) { this.request(this.learningAt(slot) ? "cancel" : "learn", { slot }) },
learningAt(slot) { return !!this.learning && this.learningSlot === slot },
disabled() { return !this.offroad || !!this.busy },
configured(slot) { return !!slot?.enabled && !!slot?.key },
optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null },
isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" },
onActionSelect(i, e) {
if (this.disabled()) return
const key = String(e.target.value || "")
const option = this.optionByKey(key)
const value = option?.value_type === "speed"
? Number(this.controllerSlots[i]?.value ?? option.default_value ?? 30)
: null
this.request("action", { slot: i, key, value })
},
onSpeedChange(i, e) {
if (this.disabled()) return
const key = String(this.controllerSlots[i]?.key || "")
const value = Number(e.target.value)
if (!Number.isFinite(value)) return
this.request("action", { slot: i, key, value })
},
listenLabel(slot) {
if (!this.learningAt(slot)) return "Learn Button"
const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
},
},
template: `
<div>
<div style="padding: var(--sp-3);">
<p v-if="!offroad" style="color: var(--text-muted);">Mappings can only be changed while offroad. Mapped buttons continue working onroad.</p>
<p v-if="error" style="color: var(--error);">{{ error }}</p>
<p v-if="!loading && !available && !mappings.length" style="color: var(--text-muted);">The wheel control service is starting.</p>
<div style="display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
<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" style="background:var(--error);color:var(--on-error);" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button>
</div>
<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>
<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>
</div>
<h4 style="margin:12px 0 8px;">Connected input devices</h4>
<p style="color:var(--text-muted); margin:0 0 8px;">Favorite buttons are the default, with controller-only actions below. Only the selected gamepad controls Joystick Mode.</p>
<div v-if="devices.length" style="display:grid; gap:8px;">
<div v-for="d in devices" :key="d.device_id" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }}</span>
<span class="gx-row__desc">{{ d.joystick_capable ? 'Buttons and joystick axes' : 'Buttons only' }}</span>
</div>
<button v-if="d.joystick_capable" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled()"
@click="request('joystick', { device_id: d.device_id, enabled: !(d.device_id === joystickDevice) })">
{{ d.device_id === joystickDevice ? 'Enabled for Joystick Mode' : 'Enable for Joystick Mode' }}
</button>
</div>
</div>
<p v-else style="color:var(--text-muted); margin:0;">Connect or pair a controller, macropad, or keyboard.</p>
<h4 style="margin:12px 0 8px;">On-screen Favorites</h4>
<div style="display:grid; gap:8px;">
<div v-for="(slot, i) in slots" :key="'fav'+i" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">Favorite #{{ i + 1 }}</span>
<span class="gx-row__desc">{{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }}</span>
</div>
<button v-if="configured(slot)" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled() || testing" @click="learn(i)">{{ listenLabel(i) }}</button>
<span v-if="mappingsOf(i).length" class="gx-row__desc">{{ mappingsOf(i).map(m => m.event_name).join(', ') }}</span>
</div>
</div>
<p v-if="!configured(slots[0]) && !configured(slots[1]) && !configured(slots[2])" style="color:var(--text-muted); margin:0;">Choose and enable these slots in Toggles to map buttons to them.</p>
<h4 style="margin:16px 0 8px;">Controller-only Actions</h4>
<p style="color:var(--text-muted); margin:0 0 8px;">Ten additional actions for physical buttons. These never appear as on-screen Favorites.</p>
<div style="display:grid; gap:8px;">
<div v-for="(slot, i) in controllerSlots" :key="'act'+i" class="gx-card" style="padding:var(--sp-3); display:grid; gap:8px; margin:0;">
<div class="gx-row" style="border:none; padding:0; flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">Controller Action #{{ i + 1 }}</span>
<span class="gx-row__desc">{{ slot.enabled ? (slot.label || 'Configured') : 'Not configured' }}</span>
</div>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!slot.enabled || disabled() || testing" @click="learn(actionSlotIndex(i))">{{ listenLabel(actionSlotIndex(i)) }}</button>
</div>
<select class="gx-field gx-field--full" :value="String(slot.key || '')" :disabled="disabled()" @change="onActionSelect(i, $event)">
<option value="">Not configured</option>
<option v-for="opt in controllerOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
<div v-if="isSpeedSlot(slot)" class="gx-row" style="border:none; padding:0;">
<div class="gx-row__info">
<span class="gx-row__label">Set speed ({{ speedUnit }})</span>
</div>
<input class="gx-field" type="number" inputmode="decimal" style="min-width:90px;"
:min="speedMinimum" :max="speedMaximum" step="1"
:value="Number(slot.value ?? 30)" :disabled="disabled()" @change="onSpeedChange(i, $event)" />
</div>
<div v-if="learningAt(actionSlotIndex(i))" style="color:var(--text-muted); font-size:var(--fs-sm);">Press one button on your controller, macropad, or keyboard.</div>
<div v-if="mappingsOf(actionSlotIndex(i)).length">
<span v-for="m in mappingsOf(actionSlotIndex(i))" :key="m.id || m.event_code" class="gx-chip gx-chip--dev" style="margin-right:4px;">{{ m.event_name || ('Button ' + m.event_code) }}</span>
</div>
</div>
</div>
</div>
</div>
`,
}
@@ -0,0 +1,100 @@
import { computed, reactive } from "vue"
import { showSnackbar } from "./api.js"
import { navigate, store } from "./store.js"
export function useTabRouting(basePath, tabs) {
const tab = computed(() => {
const prefix = basePath + "/"
const slug = store.route.startsWith(prefix)
? store.route.slice(prefix.length).split("/")[0]
: ""
for (const [key, s] of Object.entries(tabs)) {
if (s === slug) return key
}
return Object.keys(tabs)[0]
})
function selectTab(key) {
const slug = tabs[key]
if (slug === undefined) return
const href = slug ? `${basePath}/${slug}` : basePath
if (href !== store.route) navigate(href)
}
return { tab, selectTab }
}
export function usePolling(fn, { interval = 3000, enabled = () => true } = {}) {
const state = reactive({ running: false, lastError: "", lastErrorAt: 0 })
let timer = null
let destroyed = false
const stop = () => { if (timer) { clearTimeout(timer); timer = null } }
const tick = async () => {
if (destroyed || !enabled() || document.visibilityState !== "visible") {
timer = setTimeout(tick, interval)
return
}
try {
await fn()
state.lastError = ""
} catch (e) {
state.lastError = e?.message || String(e)
state.lastErrorAt = Date.now()
}
if (!destroyed) timer = setTimeout(tick, interval)
}
const start = () => { stop(); timer = setTimeout(tick, 0) }
const destroy = () => { destroyed = true; stop() }
return { state, start, stop, destroy }
}
export function useLogStream({ endpoint, snapshotFn, interval = 2000 } = {}) {
const state = reactive({ log: "", latest: "", paused: false, transport: "idle" })
let es = null
let timer = null
let destroyed = false
const apply = (data) => {
state.latest = data || ""
if (!state.paused) state.log = state.latest
}
const snapshotFetch = async () => {
if (!snapshotFn) return
try { apply((await snapshotFn())?.data || "") } catch (e) { }
}
const stopStream = () => { if (es) { es.close(); es = null } }
const stopPolling = () => { if (timer) { clearInterval(timer); timer = null } }
const startPolling = () => {
stopStream()
state.transport = "polling"
snapshotFetch()
timer = setInterval(() => { if (!destroyed) snapshotFetch() }, interval)
}
const startStream = () => {
stopPolling()
if (!endpoint) return startPolling()
state.transport = "streaming"
es = new EventSource(endpoint)
es.onmessage = (e) => apply(e.data)
es.onerror = () => { if (snapshotFn) startPolling() }
}
const start = () => (snapshotFn ? startPolling() : startStream())
const destroy = () => { destroyed = true; stopStream(); stopPolling() }
const togglePause = () => {
state.paused = !state.paused
if (!state.paused) state.log = state.latest
}
const notify = (message, level) => showSnackbar(message, level)
return { state, start, destroy, togglePause, notify }
}
export function formatAgeSeconds(value) {
const sec = Number(value)
if (!Number.isFinite(sec) || sec < 0) return "unknown"
if (sec < 1) return "just now"
if (sec < 60) return `${Math.round(sec)}s ago`
const min = sec / 60
if (min < 60) return `${Math.round(min)}m ago`
return `${Math.round(min / 60)}h ago`
}
@@ -0,0 +1,269 @@
export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
const VEHICLE_SETTING_MAKES = {
RivianAngleControl: ["Rivian"],
TeslaCoopSteering: ["Tesla"],
NAPRadarEnabled: ["Tesla"],
NAPRadarBehindNosecone: ["Tesla"],
NAPRadarOffset: ["Tesla"],
NAPPedalEnabled: ["Tesla"],
NAPPedalCanBus: ["Tesla"],
NAPAdaptiveAccel: ["Tesla"],
NAPPedalCalibDone: ["Tesla"],
NAPPedalCalibFactor: ["Tesla"],
NAPPedalCalibZero: ["Tesla"],
GMPedalLongitudinal: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
GMDashSpoofOffsets: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
IgnoreIgnitionLine: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
LongPitch: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
RemoteStartBootsComma: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
HKGRemoteStartBootsComma: ["Genesis", "Hyundai", "Kia"],
VoltSNG: ["Chevrolet", "Holden"],
GMAutoHold: ["Chevrolet", "Holden"],
VoltOnePedalMode: ["Chevrolet", "Holden"],
RemapCancelToDistance: ["Chevrolet", "Holden"],
JeepBrakeHold: ["Jeep"],
SubaruSNG: ["Subaru"],
SubaruSNGManualParkingBrake: ["Subaru"],
SubaruStopStartOff: ["Subaru"],
SubaruAvhOnAtStartup: ["Subaru"],
ClusterOffset: ["Lexus", "Toyota"],
SNGHack: ["Lexus", "Toyota"],
ToyotaAutoHold: ["Lexus", "Toyota"],
}
export function normalizeVehicleMake(value) {
return String(value ?? "").trim().toLowerCase()
}
export function isVehicleSettingVisible(section, param, values) {
const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null)
if (!allowedMakes) return true
const selectedMake = normalizeVehicleMake(values.CarMake)
return allowedMakes.some((make) => normalizeVehicleMake(make) === selectedMake)
}
function toSelectValue(value) {
return value === null || value === undefined ? "" : String(value)
}
export function matchesSettingValueCondition(param, values) {
if (!param.visible_when_key) return true
const allowedValues = Array.isArray(param.visible_when_values) ? param.visible_when_values : []
const currentValue = toSelectValue(values[param.visible_when_key])
return allowedValues.some((value) => toSelectValue(value) === currentValue)
}
export function isSettingVisible(section, param, values) {
if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
if (param.requires_capability && !values[param.requires_capability]) return false
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
return section.name === "Favorites" || param.settings_tier === "simple"
}
export function isAdvancedHiddenByDeveloperMode(section, param, values) {
if (param.settings_tier !== "advanced") return false
if (HIDDEN_SETTING_KEYS.has(param.key)) return false
if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
if (param.requires_capability && !values[param.requires_capability]) return false
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
return true
}
export function countAdvancedHiddenByDeveloperMode(layout, values) {
if (values[GALAXY_DEVELOPER_MODE_KEY]) return 0
let count = 0
for (const section of layout) {
if (section.name === "Favorites") continue
for (const param of section.params || []) {
if (isAdvancedHiddenByDeveloperMode(section, param, values)) count++
}
}
return count
}
export function numericBounds(param, values) {
const defaultBounds = {
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),
step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
}
const toFinite = (value) => {
const n = Number(value)
return Number.isFinite(n) ? n : null
}
if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") {
return { min: 1, max: 101, step: 1 }
}
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
return { min: 25, max: 200, step: 1 }
}
if (param.key === "SteerKP") {
const base = toFinite(values?.SteerKPStock) || toFinite(values?.SteerKP) || 0.6
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
if (param.key === "SteerLatAccel") {
const base = toFinite(values?.SteerLatAccelStock) || toFinite(values?.SteerLatAccel) || 2.0
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
}
if (param.key === "SteerRatio") {
const base = toFinite(values?.SteerRatioStock) || toFinite(values?.SteerRatio) || 15.0
return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
return defaultBounds
}
export function stepPrecision(step, explicitPrecision) {
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
const parsed = Number.parseInt(explicitPrecision, 10)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const stepStr = String(step ?? "")
if (!stepStr.includes(".")) return 0
return stepStr.split(".")[1].length
}
export function numericEpsilon(precision) {
return Math.pow(10, -(precision + 2))
}
export function clampNumeric(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
const value = Number(rawValue)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
const clamped = clampNumeric(value, min, max)
if (!Number.isFinite(step) || step <= 0) {
return clampNumeric(Number(clamped.toFixed(precision)), min, max)
}
const snapped = min + Math.round((clamped - min) / step) * step
return clampNumeric(Number(snapped.toFixed(precision)), min, max)
}
export function coerceValueByType(rawValue, dataType) {
if (dataType === "int") {
const n = Number.parseInt(rawValue, 10)
return Number.isFinite(n) ? n : rawValue
}
if (dataType === "float") {
const n = Number.parseFloat(rawValue)
return Number.isFinite(n) ? n : rawValue
}
return rawValue
}
export 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()
}
export function formatReadoutValue(p, value) {
const raw = value
const parsed = parseFloat(raw)
if (raw === undefined || raw === null || Number.isNaN(parsed)) return "--"
const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2
const formatted = Number(parsed.toFixed(Math.max(0, precision))).toString()
return p.unit ? `${formatted}${p.unit}` : formatted
}
export function normalizeHexColor(rawValue) {
const value = String(rawValue ?? "").trim()
if (!value || value.toLowerCase() === "stock") return ""
const stripped = value.startsWith("#") ? value.slice(1) : value
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(stripped)) return ""
return `#${stripped.slice(0, 6).toLowerCase()}`
}
export function getColorDefault(param) {
const candidate = normalizeHexColor(param?.default_color)
if (candidate) return candidate
return { LaneLinesColor: "#00ff00", PathEdgesColor: "#00ff00", PathColor: "#30ff9c" }[param?.key] || "#ffffff"
}
export function slugifySectionName(name) {
return String(name || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function isGroupParam(param) {
return !!param && param.ui_type === "group"
}
export function applyParamChange(values, patch) {
const next = { ...(values || {}) }
if (!patch || typeof patch !== "object") return next
if ("key" in patch && "value" in patch) {
next[patch.key] = patch.value
for (const [k, v] of Object.entries(patch)) {
if (k !== "key" && k !== "value") next[k] = v
}
} else {
Object.assign(next, patch)
}
return next
}
export function isParamEnabledForChildren(paramOrKey, values) {
const param = typeof paramOrKey === "string" ? { key: paramOrKey } : paramOrKey
if (isGroupParam(param)) return true
return !!(param && param.key && values[param.key])
}
export function hasChildParams(paramsList, key) {
return (paramsList || []).some((param) => (param.parent_key || null) === key)
}
export function buildRenderTree(paramsList, values, expanded, isVisible) {
const out = []
const list = paramsList || []
const visible = isVisible || (() => true)
function walk(parentKey, depth) {
for (const param of list) {
if ((param.parent_key || null) !== parentKey) continue
if (!visible(param)) continue
out.push({ param, depth })
if (hasChildParams(list, param.key) && isParamEnabledForChildren(param, values) && expanded[param.key]) {
walk(param.key, depth + 1)
}
}
}
walk(null, 0)
return out
}
@@ -0,0 +1,122 @@
import { reactive } from "vue"
const THEME_KEY = "galaxy-theme"
function initialTheme() {
return localStorage.getItem(THEME_KEY) || "dark"
}
export const store = reactive({
route: "/",
params: {},
drawerOpen: false,
search: "",
snackbar: null,
online: false,
deviceStatus: "Parked",
history: ["/"],
theme: initialTheme(),
})
export function setTheme(theme) {
const next = theme === "light" ? "light" : "dark"
store.theme = next
document.documentElement.setAttribute("data-theme", next)
try { localStorage.setItem(THEME_KEY, next) } catch (e) {}
}
export function toggleTheme() {
setTheme(store.theme === "dark" ? "light" : "dark")
}
export function parseHash(hash) {
const raw = hash.replace(/^#/, "") || "/"
const [pathname, queryString] = raw.split("?")
const params = {}
if (queryString) {
for (const pair of queryString.split("&")) {
const [k, v] = pair.split("=")
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || "")
}
}
return { path: pathname, params }
}
// Rebuild the canonical hash string for a route, preserving its query params.
export function toHash(route) {
const { path, params } = parseHash(route)
const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&")
return qs ? `${path}?${qs}` : path
}
function currentPath() {
return parseHash(window.location.hash).path
}
function pushIfNew(route) {
const last = store.history[store.history.length - 1]
if (last !== route) store.history.push(route)
}
function applyRoute(route, { scrollToTop = false } = {}) {
const { path, params } = parseHash(route)
const pathChanged = path !== store.route
store.route = path
store.params = params
store.drawerOpen = false
// Only jump to the top for real view changes. In-place hash updates (a Manage
// panel opening under the same section, an embed switching src) must not yank
// the reader back to the top of the page.
if (scrollToTop || pathChanged) window.scrollTo(0, 0)
}
export function navigate(target) {
const { path } = parseHash(target)
if (path === currentPath()) {
applyRoute(target)
window.location.hash = toHash(target)
return
}
pushIfNew(path)
applyRoute(target)
window.location.hash = toHash(target)
}
export function goHome() {
navigate("/")
}
export function goBack() {
const current = currentPath()
if (store.history[store.history.length - 1] === current && store.history.length > 1) {
store.history.pop()
}
const prev = store.history[store.history.length - 1] || "/"
applyRoute(prev, { scrollToTop: true })
window.location.hash = prev
}
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"])
export function toolHref(link) {
const path = link.split("?")[0]
if (NATIVE_ROOTS.has(path) || path.startsWith("/settings/") || path.startsWith("/embed")) return link
return "/embed?src=" + encodeURIComponent(path)
}
export function initRouter() {
const apply = () => {
const route = (window.location.hash || "").replace(/^#/, "") || "/"
const { path, params } = parseHash(route)
const pathChanged = path !== store.route
store.route = path
store.params = params
store.drawerOpen = false
pushIfNew(route)
if (pathChanged) window.scrollTo(0, 0)
}
window.addEventListener("hashchange", apply)
store.history = ["/"]
apply()
setTheme(store.theme)
}
@@ -0,0 +1,11 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const Home = {
name: "Home",
components: { GalaxyEmbed },
template: `
<div class="gx-view">
<GalaxyEmbed src="/classic" title="Home" />
</div>
`,
}

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