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
103 changed files with 22569 additions and 118 deletions
+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 "$@"
;; ;;
@@ -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
+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()))
@@ -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>
`,
}
@@ -0,0 +1,246 @@
import { api, showSnackbar } from "../api.js"
import { useLogStream } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
function parseLogDate(filename) {
const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
if (!m) return new Date()
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0))
return isNaN(date.getTime()) ? new Date() : date
}
export const Logs = {
name: "Logs",
components: { GalaxyEmbed },
data() {
return {
tab: "errors",
errorFiles: [],
errorsLoading: true,
selectedLog: "",
logContent: "",
logLoading: false,
tmuxFiles: [],
tmuxLoading: true,
troubleshootData: null,
troubleshootRunning: false,
searchFilter: "",
tmuxAutoScroll: true,
}
},
created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
},
watch: {
"stream.state.log"() {
this.$nextTick(() => {
if (this.stream.state.paused || !this.tmuxAutoScroll) return
const el = this.$refs.tmuxtail
if (el) el.scrollTop = el.scrollHeight
})
},
},
mounted() {
this.loadErrorLogs()
this.loadTmuxLogs()
this.stream.start()
this.loadTroubleshoot()
},
beforeUnmount() { this.stream.destroy() },
computed: {
filteredErrorFiles() {
if (!this.searchFilter) return this.errorFiles
const q = this.searchFilter.toLowerCase()
return this.errorFiles.filter((f) => f.filename.toLowerCase().includes(q))
},
},
methods: {
async loadErrorLogs() {
try {
const files = await api.getErrorLogs()
this.errorFiles = files.map((f) => {
const date = parseLogDate(f)
return { filename: f, date: date.toLocaleString() }
})
} catch (e) {
showSnackbar("Failed to load error logs.", "error")
} finally {
this.errorsLoading = false
}
},
async viewLog(file) {
this.selectedLog = file.filename
this.logLoading = true
try {
this.logContent = await api.getErrorLog(file.filename)
} catch (e) {
this.logContent = "Could not load this log."
} finally {
this.logLoading = false
this.$nextTick(() => {
const el = this.$refs.logview
if (el) el.scrollTop = el.scrollHeight
})
}
},
async deleteLog(file) {
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
await api.deleteErrorLog(file.filename)
this.errorFiles = this.errorFiles.filter((f) => f.filename !== file.filename)
if (this.selectedLog === file.filename) this.selectedLog = ""
showSnackbar("Log deleted!")
},
async deleteAllLogs() {
if (!(await GalaxyConfirm({ title: "Delete all error logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
try {
const ok = await api.deleteAllErrorLogs()
if (!ok) throw new Error("Delete failed")
this.errorFiles = []
this.selectedLog = ""
showSnackbar("All error logs deleted!")
} catch (e) {
showSnackbar("Delete all failed.", "error")
}
},
copyLog() {
const text = this.logContent
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text)
else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove() }
showSnackbar("Copied to clipboard!")
},
async loadTmuxLogs() {
try {
const files = await api.getTmuxLogs()
this.tmuxFiles = files.map((f) => {
const date = new Date(f.timestamp * 1000)
return { filename: f.filename, date: date.toLocaleString() }
})
} catch (e) {
this.tmuxFiles = []
} finally {
this.tmuxLoading = false
}
},
async captureTmux() {
const ok = await api.tmuxCapture()
showSnackbar(ok ? "Current session captured!" : "Capture failed.", ok ? "info" : "error")
this.tmuxLoading = true
await this.loadTmuxLogs()
},
async deleteTmux(file) {
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
const ok = await api.deleteTmuxLog(file.filename)
showSnackbar(ok ? "Deleted!" : "Delete failed.", ok ? "info" : "error")
this.tmuxFiles = this.tmuxFiles.filter((f) => f.filename !== file.filename)
},
async deleteAllTmux() {
if (!(await GalaxyConfirm({ title: "Delete all logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
const ok = await api.deleteAllTmuxLogs()
showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
this.tmuxFiles = []
},
async loadTroubleshoot() {
try {
this.troubleshootData = await api.getTroubleshoot()
} catch (e) { this.troubleshootData = null }
},
async runTroubleshoot() {
this.troubleshootRunning = true
try {
this.troubleshootData = await api.runTroubleshoot()
} catch (e) {
showSnackbar("Troubleshoot failed.", "error")
} finally {
this.troubleshootRunning = false
}
},
async resetTroubleshoot() {
const ok = await api.resetTroubleshoot()
if (ok) this.troubleshootData = null
showSnackbar(ok ? "Troubleshoot reset!" : "Reset failed.", ok ? "info" : "error")
},
onTmuxScroll(e) {
const el = e.target
this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
},
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Logs & Diagnostics</h2>
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
<button type="button" class="gx-chip" :style="tab==='errors'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='errors'">Error Logs</button>
<button type="button" class="gx-chip" :style="tab==='tmux'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='tmux'">Tmux Live Log</button>
<button type="button" class="gx-chip" :style="tab==='troubleshoot'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='troubleshoot'">Troubleshoot</button>
</div>
<template v-if="tab === 'errors'">
<div style="display:flex; gap:8px; margin-bottom:8px; align-items:center;">
<input class="gx-field" style="flex:1;" type="search" v-model="searchFilter" placeholder="Search logs..." />
<button v-if="errorFiles.length" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllLogs()">Delete All</button>
</div>
<section class="gx-card">
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
<div v-else-if="!filteredErrorFiles.length" class="gx-empty">No error logs!</div>
<div v-for="f in filteredErrorFiles" :key="f.filename" class="gx-row" style="cursor:pointer;" @click="viewLog(f)">
<div class="gx-row__info">
<span class="gx-row__label">{{ f.date }}</span>
<span class="gx-row__desc">{{ f.filename }}</span>
</div>
<div style="display:flex; gap:6px;">
<a class="gx-btn gx-btn--tonal" :href="'/api/error_logs/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click.stop="deleteLog(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
<div v-if="selectedLog" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-file-text"></i>
<span class="gx-section__title">{{ selectedLog }}</span>
<button type="button" class="gx-btn gx-btn--tonal" @click="copyLog"><i class="bi bi-clipboard"></i> Copy</button>
</div>
<pre ref="logview" style="max-height:60vh; overflow:auto; padding:var(--sp-3); font-size:12px; line-height:1.5; white-space:pre-wrap;">{{ logLoading ? 'Loading...' : logContent }}</pre>
</div>
</template>
<template v-else-if="tab === 'tmux'">
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-terminal"></i>
<span class="gx-section__title">Tmux Live Log</span>
<span class="gx-chip" :style="stream.state.transport === 'streaming' ? 'background:var(--success);' : ''">{{ stream.state.transport }}</span>
</div>
<pre ref="tmuxtail" class="gx-terminal" @scroll.passive="onTmuxScroll">{{ stream.state.log || '(waiting for log output…)' }}</pre>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" @click="stream.togglePause()">{{ stream.state.paused ? '▶️ Resume' : '⏸️ Pause' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="captureTmux">Capture Log</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllTmux">Delete All</button>
</div>
</section>
<section class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-collection"></i>
<span class="gx-section__title">Saved Session Logs</span>
</div>
<div v-if="tmuxLoading" class="gx-loading">Loading...</div>
<div v-else-if="!tmuxFiles.length" class="gx-empty">No tmux logs found.</div>
<div v-for="f in tmuxFiles" :key="f.filename" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ f.filename }}</span>
<span class="gx-row__desc">{{ f.date }}</span>
</div>
<div style="display:flex; gap:6px;">
<a class="gx-btn gx-btn--tonal" :href="'/api/tmux_log/download/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteTmux(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
</template>
<template v-else>
<GalaxyEmbed src="/troubleshoot" title="Troubleshoot" />
</template>
</div>
`,
}
@@ -0,0 +1,89 @@
import { api, showSnackbar } from "../api.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { useTabRouting } from "../composables.js"
export const Navigation = {
name: "Navigation",
components: { GalaxySection, GalaxyEmbed },
data() {
return { destination: "", favorites: [], navLoading: true }
},
setup() {
return useTabRouting("/navigation", {
nav: "", maps: "maps", keys: "keys", speeds: "speeds",
})
},
computed: {
embedSrc() {
const sources = {
maps: "/manage_maps",
keys: "/manage_navigation_keys",
speeds: "/download_speed_limits",
}
return sources[this.tab] || ""
},
embedTitle() {
const titles = {
maps: "Maps",
keys: "App Keys",
speeds: "Speed Limits",
}
return titles[this.tab] || "Navigation"
},
},
mounted() { this.loadNavigation() },
methods: {
async loadNavigation() {
this.navLoading = true
try {
const data = await api.getNavigation()
this.destination = data?.destination || data?.name || ""
this.favorites = Array.isArray(data?.favorites) ? data.favorites : []
} catch (e) {
this.favorites = []
} finally {
this.navLoading = false
}
},
async setDestination() {
if (!this.destination) return
try {
const payload = await api.setNavigation({ destination: this.destination })
showSnackbar(payload?.message || "Destination set.")
} catch (e) {
showSnackbar(e?.message || "Failed to set destination.", "error")
}
},
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Navigation & Maps</h2>
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
<button type="button" class="gx-chip" :style="tab==='nav'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('nav')">Destination</button>
<button type="button" class="gx-chip" :style="tab==='maps'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('maps')">Maps</button>
<button type="button" class="gx-chip" :style="tab==='keys'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('keys')">App Keys</button>
<button type="button" class="gx-chip" :style="tab==='speeds'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('speeds')">Speed Limits</button>
</div>
<template v-if="tab === 'nav'">
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<input class="gx-field" v-model="destination" placeholder="Destination address or name" />
<button type="button" class="gx-btn" @click="setDestination"><i class="bi bi-send"></i> Send to Device</button>
<div v-if="favorites.length">
<h4 style="margin:12px 0 8px;">Favorites</h4>
<div v-for="fav in favorites" :key="fav.name" class="gx-row">
<span class="gx-row__label">{{ fav.name }}</span>
<button type="button" class="gx-btn gx-btn--tonal" @click="destination = fav.name; setDestination()">Use</button>
</div>
</div>
</div>
</GalaxySection>
</template>
<GalaxyEmbed v-else-if="embedSrc" :src="embedSrc" :title="embedTitle" />
</div>
`,
}
@@ -0,0 +1,305 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
function fmtDuration(seconds) {
seconds = Number(seconds) || 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatBytes(bytes) {
if (!bytes) return "0 MB"
const mb = bytes / 1e6
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
}
function normalizeRoute(r) {
const name = String(r?.name || "")
const isCustomName = !!r?.isCustomName
return {
name,
displayName: r?.displayName || name.split("--").pop() || name,
displayDate: r?.displayDate || "",
approxDurationSeconds: Number(r?.approxDurationSeconds || 0),
segmentCount: Number(r?.segmentCount || r?.numSegments || 0),
is_preserved: !!r?.is_preserved,
isCustomName,
png: r?.png || "",
}
}
export const Recordings = {
name: "Recordings",
components: { GalaxySection },
data() {
return {
loading: true,
error: "",
routes: [],
progress: 0,
searchQuery: "",
sortOrder: "newest",
showPreservedOnly: false,
playerRoute: null,
playerLoading: false,
playerError: "",
segments: [],
current: 0,
cameras: [],
selectedCamera: "",
logsRoute: null,
logsData: null,
}
},
computed: {
stats() {
return {
count: this.routes.length,
formattedDuration: fmtDuration(this.routes.reduce((n, r) => n + r.approxDurationSeconds, 0)),
preservedCount: this.routes.filter((r) => r.is_preserved).length,
}
},
visibleRoutes() {
let list = this.routes.slice()
if (this.showPreservedOnly) list = list.filter((r) => r.is_preserved)
if (this.searchQuery.trim()) {
const q = this.searchQuery.toLowerCase()
list = list.filter((r) => [r.displayName, r.displayDate, r.name].some((v) => String(v || "").toLowerCase().includes(q)))
}
const sorters = {
newest: (a, b) => (b.name > a.name ? 1 : -1),
oldest: (a, b) => (a.name > b.name ? 1 : -1),
longest: (a, b) => b.approxDurationSeconds - a.approxDurationSeconds,
shortest: (a, b) => a.approxDurationSeconds - b.approxDurationSeconds,
}
return list.sort(sorters[this.sortOrder] || sorters.newest)
},
},
methods: {
fmtDuration,
formatBytes,
async loadRoutes() {
this.loading = true
this.error = ""
this.routes = []
this.progress = 0
const seen = new Set()
try {
this.controller?.abort()
this.controller = new AbortController()
await api.getRoutesStream({
signal: this.controller.signal,
onProgress: (p) => { this.progress = p },
onRoutes: (raw) => {
for (const r of raw) {
if (seen.has(r.name)) continue
seen.add(r.name)
this.routes.push(normalizeRoute(r))
}
},
})
} catch (e) {
if (e?.name !== "AbortError") this.error = "Couldn't load routes. Try refreshing."
} finally {
this.loading = false
}
},
async deleteRoute(route) {
if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return
try {
await api.deleteRoute(route.name)
this.routes = this.routes.filter((r) => r.name !== route.name)
showSnackbar("Route deleted!")
} catch (e) {
showSnackbar("Delete failed.", "error")
}
},
async togglePreserved(route) {
try {
await api.setRoutePreserved(route.name, !route.is_preserved)
route.is_preserved = !route.is_preserved
} catch (e) {
showSnackbar("Failed to update preserved state.", "error")
}
},
async renameRoute(route) {
const newName = prompt("Rename route:", route.displayName)
if (!newName || newName === route.displayName) return
try {
const payload = await api.renameRoute(route.name, newName)
Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true }))
route.displayName = payload.name || newName
showSnackbar("Route renamed!")
} catch (e) {
showSnackbar("Rename failed.", "error")
}
},
async deleteAllRoutes(includePreserved) {
const label = includePreserved ? "Delete all routes, including preserved?" : "Delete all non-preserved routes?"
if (!(await GalaxyConfirm({ title: label, message: "This action cannot be undone.", confirmLabel: includePreserved ? "Delete Everything" : "Delete Non-Preserved", danger: true }))) return
try {
const payload = await api.deleteAllRoutes(includePreserved)
this.routes = []
showSnackbar(payload?.message || "Routes deleted!")
} catch (e) {
showSnackbar("Failed to delete routes.", "error")
}
},
async openPlayer(route) {
this.playerRoute = route
this.playerLoading = true
this.playerError = ""
try {
const data = await api.getRoute(route.name)
const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : []
const cameras = ["forward", "wide", "driver"].filter((c) => data.available_cameras?.includes(c))
if (!segments.length) throw new Error("No video segments for this route.")
if (!cameras.length) throw new Error("No camera video for this route.")
this.segments = segments
this.current = 0
this.cameras = cameras
this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
this.$nextTick(() => this.playSegment())
} catch (e) {
this.playerError = e?.message || "Could not load route."
} finally {
this.playerLoading = false
}
},
cameraUrl(url, low) {
if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url
const sep = url.includes("?") ? "&" : "?"
return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}`
},
playSegment() {
const video = this.$refs.player
if (!video || !this.segments[this.current]) return
video.src = this.cameraUrl(this.segments[this.current])
video.load()
video.play().catch(() => {})
},
downloadRoute() {
if (!this.playerRoute) return
const a = document.createElement("a")
a.href = `/video/${this.playerRoute.name}/combined?camera=${encodeURIComponent(this.selectedCamera)}`
a.download = `${this.playerRoute.displayName}-${this.selectedCamera}.mp4`
a.click()
},
closePlayer() {
if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") }
this.playerRoute = null
this.playerLoading = false
this.playerError = ""
this.segments = []
this.cameras = []
},
async openLogs(route) {
try {
this.logsData = await api.getRouteLogs(route.name)
this.logsRoute = route
} catch (e) {
showSnackbar("Could not read logs.", "error")
}
},
},
async mounted() { await this.loadRoutes() },
beforeUnmount() { this.controller?.abort() },
template: `
<div>
<h2 style="margin-top:0;">Recordings</h2>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-camera-reels"></i>
<span class="gx-section__title">Dashcam Routes</span>
<span class="gx-section__count">{{ stats.count }} drives · {{ stats.formattedDuration }}</span>
</div>
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<input class="gx-field" style="flex:1; min-width:160px;" type="search" placeholder="Search routes..." v-model="searchQuery" />
<select class="gx-field" v-model="sortOrder">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="longest">Longest duration</option>
<option value="shortest">Shortest duration</option>
</select>
<button type="button" class="gx-chip" :style="!showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=false">All</button>
<button type="button" class="gx-chip" :style="showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=true">Preserved</button>
</div>
</section>
<div v-if="loading" class="gx-loading">Finding local routes... ({{ Math.round(progress) }}%)</div>
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
<section class="gx-card">
<div v-if="!visibleRoutes.length && !loading" class="gx-empty">No routes found.</div>
<article v-for="r in visibleRoutes" :key="r.name" class="gx-row" style="cursor:pointer;" @click="openPlayer(r)">
<div class="gx-row__info">
<span class="gx-row__label">{{ r.displayName }} <span v-if="r.is_preserved" class="gx-chip gx-chip--dev">Preserved</span></span>
<span class="gx-row__desc">{{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments</span>
</div>
<div style="display:flex; gap:6px;">
<button type="button" class="gx-btn gx-btn--tonal" title="Preserve" @click.stop="togglePreserved(r)"><i class="bi" :class="r.is_preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Logs" @click.stop="openLogs(r)"><i class="bi bi-file-earmark-arrow-down"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRoute(r)"><i class="bi bi-pencil"></i></button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
</div>
</article>
</section>
<section class="gx-card" v-if="routes.length">
<div class="gx-section__header">
<i class="bi bi-exclamation-triangle"></i>
<span class="gx-section__title">Delete local routes</span>
</div>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllRoutes(false)">Delete Non-Preserved</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
</div>
</section>
<div v-if="logsRoute && logsData" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-file-earmark-arrow-down"></i>
<span class="gx-section__title">{{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }}</span>
<a class="gx-btn gx-btn--tonal" :href="'/api/routes/' + logsRoute.name + '/logs/download'" download>Download all (.tar)</a>
</div>
<div v-for="seg in logsData.segments || []" :key="seg.segmentNum" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">Segment {{ seg.segmentNum }}</span>
<span class="gx-row__desc">{{ seg.filename }} · {{ formatBytes(seg.bytes) }}</span>
</div>
<a class="gx-btn gx-btn--tonal" :href="seg.url" download>Download</a>
</div>
</div>
<transition name="gx-fade">
<div v-if="playerRoute" class="gx-scrim" @click.self="closePlayer">
<div class="gx-sheet" style="max-width:640px; width:100%;" role="dialog" aria-label="Route video player">
<div class="gx-section__header" style="cursor:default;">
<i class="bi bi-camera-video"></i>
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
</div>
<div style="padding: var(--sp-3);">
<div v-if="playerError" class="gx-empty" style="color: var(--error);">{{ playerError }}</div>
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
<template v-else-if="segments.length">
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
</select>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-skip-end-fill"></i></button>
<button v-for="c in cameras" :key="c" type="button" class="gx-chip" :style="selectedCamera===c?'background:var(--primary);color:var(--on-primary);':''" @click="selectedCamera=c; playSegment()">{{ c }}</button>
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</button>
</div>
</template>
</div>
</div>
</div>
</transition>
</div>
`,
}
@@ -0,0 +1,166 @@
import { api, showSnackbar } from "../api.js"
import { navigate, store } from "../store.js"
import {
applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible,
slugifySectionName,
} from "../params.js"
import { SettingTree } from "../components/SettingTree.js"
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
export const Settings = {
name: "Settings",
components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner },
data() {
return {
layout: [],
values: {},
expanded: {},
loading: true,
activeSectionSlug: "",
defaultSectionSlug: "lateral-steering",
}
},
computed: {
devModeOn() { return !!this.values[GALAXY_DEVELOPER_MODE_KEY] },
route() { return store.route },
sections() {
return this.layout
.filter((s) => s.name !== "Model & Customization")
.map((s) => ({
...s,
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
},
activeSection() {
return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0]
},
hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
searchActive() { return !!this.searchTerm },
searchTerm: {
get() { return store.search },
set(v) { store.search = v },
},
searchResults() {
if (!this.searchActive) return []
return this.sections
.map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
.filter((s) => s.matches.length > 0)
},
},
methods: {
async load() {
try {
const [layout, values, defaults] = await Promise.all([
api.getLayout(), api.getParams(), api.getDefaults(),
])
this.layout = layout
this.values = values || {}
this.defaults = defaults || {}
if (!this.activeSectionSlug && this.sections.length) {
const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
this.activeSectionSlug = (preferred || this.sections[0]).slug
}
} catch (e) {
showSnackbar("Failed to load settings: " + (e?.message || e), "error")
} finally {
this.loading = false
}
},
onParamChange(patch) {
this.values = applyParamChange(this.values, patch)
},
toggleManage(key) {
const next = !this.expanded[key]
this.expanded = { ...this.expanded, [key]: next }
const base = "/settings/" + this.activeSectionSlug
window.location.hash = next ? `${base}?open=${encodeURIComponent(key)}` : base
},
matchesFilter(p) {
if (!this.searchTerm) return true
const q = this.searchTerm.toLowerCase()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
},
selectSection(slug) {
if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
},
applyRouteSection() {
const route = store.route
if (!route.startsWith("/settings/")) return
const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0]
if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true }
},
lockReason(param) {
if (param?.requires_offroad && this.values.IsOnroad) return "This setting can only be changed while parked."
if (param?.requires_parked && !this.values.VehicleParked) return "This setting can only be changed while the vehicle is in Park."
if (param?.disabled_when_key_true && this.values[param.disabled_when_key_true]) return param.disabled_reason || "Disabled by another setting."
if (param?.requires_nonempty_key) {
const val = this.values[param.requires_nonempty_key]
if (!val || val === "{}" || val === "") return param.disabled_reason || "Required configuration missing."
}
return ""
},
},
watch: {
route() { this.applyRouteSection() },
devModeOn() { this.load() },
},
async mounted() {
await this.load()
this.applyRouteSection()
},
template: `
<div>
<h2 style="margin-top:0;">Toggles</h2>
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
<div v-if="loading" class="gx-loading">Loading configuration...</div>
<template v-else-if="sections.length">
<div v-if="searchActive">
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-search"></i>
<span class="gx-section__title">{{ searchResults.reduce((n, s) => n + s.matches.length, 0) }} result(s)</span>
</div>
</div>
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<template v-for="p in section.matches" :key="p.key">
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
@change="onParamChange" />
</template>
</GalaxySection>
</template>
</div>
<div v-else>
<div class="gx-tabs" style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
<button v-for="s in sections" :key="s.slug" type="button"
class="gx-chip" :style="s.slug === activeSection.slug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
@click="selectSection(s.slug)">
{{ s.name }}
</button>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ activeSection.name }}</span>
</div>
<SettingTree :params="activeSection.params" :parent-key="null" :values="values"
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
</div>
</div>
</template>
<div v-else class="gx-empty">No settings available.</div>
</div>
`,
}
@@ -0,0 +1,261 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
function shortCommit(commit) {
return String(commit || "").slice(0, 10) || "—"
}
export const SystemTools = {
name: "SystemTools",
components: { GalaxySection, GalaxyEmbed },
data() {
return {
branches: [],
currentBranch: "",
branchLoading: true,
fastStatus: null,
checkedForUpdates: false,
busy: "",
}
},
created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
mounted() { this.loadBranches() },
beforeUnmount() { this.poll?.destroy() },
computed: {
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
},
methods: {
shortCommit,
async loadBranches() {
try {
const data = await api.getUpdateBranches()
this.branches = Array.isArray(data?.branches) ? data.branches : []
this.currentBranch = data?.currentBranch || ""
this.isOnroad = !!data?.isOnroad
} catch (e) {
showSnackbar("Failed to load update info.", "error")
} finally {
this.branchLoading = false
}
},
async loadFastStatus() {
try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null }
},
async backupToggles() {
try {
const blob = await api.backupToggles()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "toggle-backup.json"
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
showSnackbar("Toggle backup downloaded.")
} catch (e) {
showSnackbar(e?.message || "Backup failed.", "error")
}
},
onRestoreFile(e) {
const file = e.target.files[0]
e.target.value = ""
if (!file) return
if (file.size > 5_000_000) { showSnackbar("That toggle backup file is too large.", "error"); return }
file.text().then((text) => {
let data
try { data = JSON.parse(text) } catch { showSnackbar("That file is not a valid toggle backup.", "error"); return }
if (!data || typeof data !== "object" || Array.isArray(data)) { showSnackbar("That file is not a valid toggle backup.", "error"); return }
api.restoreToggles(data).then((res) => {
showSnackbar(res?.message || "Toggles restored!")
}).catch((err) => showSnackbar(err?.message || "Failed to restore toggles.", "error"))
})
},
async resetDefault() {
if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return
try {
await api.resetTogglesDefault()
showSnackbar("Resetting toggles to default... rebooting.")
} catch (e) {
showSnackbar("Reset failed.", "error")
}
},
onBranchSelect(e) {
const branch = e.target.value
e.target.value = this.currentBranch || ""
this.switchBranch(branch)
},
async switchBranch(branch) {
if (!branch || branch === this.currentBranch) return
if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
try {
await api.setUpdateBranch(branch)
showSnackbar(`Switching to ${branch}...`)
} catch (e) {
showSnackbar(e?.message || "Switch failed.", "error")
}
},
async checkUpdates() {
if (this.busy) return
this.busy = "check"
try {
await this.loadFastStatus()
this.checkedForUpdates = true
const st = this.fastStatus
if (st?.running) showSnackbar("An update is already running.")
else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
else showSnackbar(st?.message || "No update available — you're up to date.")
} catch (e) {
showSnackbar("Failed to check for updates.", "error")
} finally {
this.busy = ""
}
},
async applyFastUpdate() {
if (this.busy || this.isOnroad) return
if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
if (!this.checkedForUpdates || !this.updateAvailable) {
showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
return
}
const st = this.fastStatus
const confirmed = await GalaxyConfirm({
title: "Update available",
message: `Fast update to the latest commit on ${st?.branch || "this branch"}.\n\nYour device will reboot when the update is done.`,
confirmLabel: "Update & Reboot",
danger: true,
})
if (!confirmed) return
await this.runUpdate("fast")
},
async runUpdate(action) {
if (this.busy) return
this.busy = action
try {
if (action === "rollback") {
const st = this.fastStatus
if (st && !st.rollbackAvailable) {
showSnackbar("No previous installed version is available to roll back to.", "error")
return
}
}
if (action !== "fast") {
const actionLabels = { recover: "Recover the interrupted update?", rollback: "Roll back to the previous installed version?" }
if (!(await GalaxyConfirm({ title: actionLabels[action] || "Continue?", message: "Your device will reboot when the operation is done.", confirmLabel: "Continue", danger: true }))) return
}
const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback
const payload = await fn()
showSnackbar(payload?.message || "Update started.")
await this.loadFastStatus()
} catch (e) {
showSnackbar(e?.message || "Update failed.", "error")
} finally {
this.busy = ""
}
},
async factoryReset() {
if (!(await GalaxyConfirm({ title: "Factory reset?", message: "This wipes params, backups, themes, models, maps, and route data, then reboots. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
try {
await api.factoryReset()
showSnackbar("Factory resetting...")
} catch (e) {
showSnackbar(e?.message || "Factory reset failed.", "error")
}
},
async saveMe() {
if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
try {
await api.factoryReset()
showSnackbar("SAVE ME initiated — factory resetting...")
} catch (e) {
showSnackbar(e?.message || "Factory reset failed.", "error")
}
},
async deleteAllDrivingRoutes() {
if (!(await GalaxyConfirm({ title: "Delete All Driving Routes", message: "This permanently deletes all local routes from standard, high-resolution, and alternate footage storage. It does not reset settings or reboot the device.", confirmLabel: "Delete Routes", danger: true }))) return
try {
const payload = await api.deleteAllRoutes(true)
showSnackbar(payload?.message || "All local driving routes deleted.")
} catch (e) {
showSnackbar(e?.message || "Failed to delete driving routes.", "error")
}
},
},
template: `
<div>
<h2 style="margin-top:0;">System Tools</h2>
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat">
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="$refs.restoreInput.click()"><i class="bi bi-upload"></i> Restore Toggles</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="resetDefault">Reset to Default</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="saveMe">SAVE ME</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllDrivingRoutes">Delete All Driving Routes</button>
<input ref="restoreInput" type="file" accept=".json" style="display:none;" @change="onRestoreFile" />
</div>
<GalaxyEmbed src="/manage_toggles" title="Backup & Restore" style="min-height:60vh; margin: var(--sp-3);" />
</GalaxySection>
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle">
<div style="padding: var(--sp-3);">
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
<template v-else>
<p v-if="isOnroad" style="color:var(--text-muted);">Updates and branch switching are only available while offroad.</p>
<div v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header">
<i class="bi bi-arrow-repeat"></i>
<span class="gx-section__title">Update Status</span>
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
<span v-else-if="fastStatus.updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else class="gx-chip">Up to date</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '' }}</span></div>
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
<div v-if="fastStatus.message" class="gx-row__desc">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-row__desc" style="color:var(--warning);">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-row__desc" style="color:var(--warning);"> {{ w }}</div>
</div>
</div>
</div>
<h4 style="margin:12px 0 8px;">Switch branch</h4>
<div class="gx-row" style="border-top:none; padding:4px 0;">
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
<option v-if="!branches.length" value="">No branches available</option>
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
</select>
</div>
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
</button>
<button type="button" class="gx-btn" :disabled="!updateAvailable || !!busy || isOnroad" @click="applyFastUpdate">
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
</div>
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" style="color:var(--text-muted); margin:8px 0 0;">
The device is up to date. Update becomes available only after a check finds a newer commit.
</p>
</template>
</div>
</GalaxySection>
<GalaxySection title="Danger Zone" icon="bi-exclamation-triangle">
<div style="padding: var(--sp-3);">
<p style="color: var(--text-muted);">Last resort only. This wipes params, backups, themes, models, maps, and route data, then reboots the device.</p>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="factoryReset">Factory Reset Device</button>
</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,46 @@
import { store } from "../store.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const ToolEmbed = {
name: "ToolEmbed",
components: { GalaxyEmbed },
computed: {
src() { return store.params.src || "/tools" },
title() {
const map = {
"/manage_models": "Model Manager",
"/galaxy": "Galaxy",
"/sentry": "Sentry Mode",
"/plots": "Live Plots",
"/download_speed_limits": "Download Speed Limits",
"/testing_ground": "Testing Ground",
"/theme_maker": "Theme Maker",
"/troubleshoot": "Troubleshoot",
"/manage_tmux": "Tmux Log",
"/manage_toggles": "Backup and Restore",
"/manage_updates": "Software",
"/manage_error_logs": "Error Logs",
"/bluetooth": "Bluetooth",
"/wheel-controls": "Controllers",
"/vehicle_features": "Vehicle Features",
"/manage_v_asm": "V-Adj Spot Monitor",
"/manage_pip_sidecam": "PiP Side Camera",
"/manage_doors": "Lock/Unlock Doors",
"/manage_tsk": "Toyota Security Keys",
"/set_navigation_destination": "Navigation Destination",
"/manage_navigation_keys": "App Keys",
"/manage_maps": "Maps",
}
return map[this.src] || "Tool"
},
},
template: `
<div class="gx-view">
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
<i class="bi bi-grid"></i>
<span class="gx-section__title">{{ title }}</span>
</div>
<GalaxyEmbed :src="src" :title="title" forward-nav />
</div>
`,
}
@@ -0,0 +1,39 @@
import { navigate, toolHref } from "../store.js"
const TOOLS = [
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video", desc: "Adjust PiP side-camera window" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow", desc: "Live telemetry plots" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2", desc: "Experiments & testing" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" },
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed behaviour" },
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
].sort((a, b) => a.name.localeCompare(b.name))
export const Tools = {
name: "Tools",
data() { return { TOOLS } },
methods: {
open(t) {
navigate(toolHref(t.link))
},
},
template: `
<div>
<h2 style="margin-top:0;">Tools</h2>
<div class="gx-grid">
<button v-for="t in TOOLS" :key="t.link" type="button" class="gx-tile" @click="open(t)">
<i class="bi" :class="t.icon"></i>
<span>{{ t.name }}</span>
<small style="color: var(--text-muted);">{{ t.desc }}</small>
</button>
</div>
</div>
`,
}
@@ -0,0 +1,15 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const Tuning = {
name: "Tuning",
components: { GalaxyEmbed },
template: `
<div class="gx-view">
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
<i class="bi bi-sign-turn-right"></i>
<span class="gx-section__title">Tuning & Maneuvers</span>
</div>
<GalaxyEmbed src="/tuning" title="Tuning" />
</div>
`,
}
@@ -0,0 +1,79 @@
import { api, showSnackbar } from "../api.js"
import { navigate, toolHref } from "../store.js"
import { WheelControls } from "../components/WheelControls.js"
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { GalaxySection } from "../components/GalaxySection.js"
const FEATURES = [
{ key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" },
{ key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" },
]
export const Vehicle = {
name: "Vehicle",
components: { WheelControls, BluetoothPanel, GalaxySection },
data() {
return {
features: FEATURES,
featureStatus: {},
busy: "",
}
},
methods: {
statusOf(key) { return this.featureStatus[key] || "untested" },
async openFeature(f) {
if (this.busy) return
if (this.statusOf(f.key) === "denied") {
showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
return
}
if (this.statusOf(f.key) === "allowed") {
navigate(toolHref(f.embed))
return
}
this.busy = f.key
try {
const data = await api.carFeaturesCheck(f.key)
const allowed = !!data?.result
this.featureStatus = { ...this.featureStatus, [f.key]: allowed ? "allowed" : "denied" }
if (allowed) navigate(toolHref(f.embed))
else showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
} catch (e) {
this.featureStatus = { ...this.featureStatus, [f.key]: "denied" }
showSnackbar("Could not check vehicle compatibility.", "error")
} finally {
this.busy = ""
}
},
},
template: `
<div>
<h2 style="margin-top:0;">Vehicle Controls</h2>
<GalaxySection title="Controllers" icon="bi-controller">
<WheelControls />
</GalaxySection>
<GalaxySection title="Bluetooth" icon="bi-bluetooth">
<BluetoothPanel />
</GalaxySection>
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<button v-for="f in features" :key="f.key" type="button"
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
@click="openFeature(f)">
<div class="gx-row__info">
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
<span class="gx-row__desc">{{ f.desc }}</span>
</div>
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
</button>
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,18 @@
{
"name": "Galaxy",
"short_name": "Galaxy",
"description": "Control and configure your openpilot device from anywhere.",
"icons": [
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/images/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
],
"id": "46bf2df73deba8e1512c35de",
"start_url": "/mobile/",
"scope": "/",
"background_color": "#06060f",
"theme_color": "#8b6cc5",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"orientation": "portrait-primary"
}
File diff suppressed because it is too large Load Diff
@@ -66,6 +66,15 @@
<script src="/assets/js/snackbar.js"></script> <script src="/assets/js/snackbar.js"></script>
<title>Galaxy</title> <title>Galaxy</title>
<script>
const _inEmbed =
new URLSearchParams(window.location.search).has("embedded") ||
(window.self !== window.top);
if (_inEmbed) {
document.documentElement.classList.add("embedded");
}
</script>
</head> </head>
<body> <body>
@@ -0,0 +1,53 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
DEVICE_SETTINGS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.js"
DEVICE_SETTINGS_CSS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.css"
def _device_settings():
return DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
def test_device_settings_surfaces_hidden_advanced_settings_count():
source = _device_settings()
assert "countAdvancedHiddenByDeveloperMode" in source
assert "isAdvancedHiddenByDeveloperMode" in source
assert "hiddenAdvancedCount" in source
def test_developer_mode_notice_is_rendered_when_advanced_settings_hidden():
source = _device_settings()
assert "ds-dev-mode-notice" in source
assert "advanced setting" in source
assert "Enable Developer Mode" in source
def test_developer_mode_notice_navigates_to_developer_section():
source = _device_settings()
assert 'window.__theGalaxyNavigate("/device_settings/developer")' in source
def test_advanced_settings_hidden_count_shown_in_status_bar():
source = _device_settings()
assert "advanced hidden" in source
def test_device_settings_uses_the_params_api_and_layout_json():
source = _device_settings()
assert 'fetch("/api/params/all")' in source
assert 'fetch("/api/params/defaults")' in source
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
def test_developer_mode_notice_has_styles():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-dev-mode-notice" in css
assert ".ds-dev-mode-notice-btn" in css
@@ -0,0 +1,437 @@
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[4]
UI_ROOT = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile"
GALAXY_PY = REPO_ROOT / "starpilot/system/the_galaxy/the_galaxy.py"
def _read(rel):
return (UI_ROOT / rel).read_text(encoding="utf-8")
def test_ui_app_shell_files_exist():
required = [
"index.html",
"manifest.json",
"css/material.css",
"js/app.js",
"js/store.js",
"js/api.js",
"js/params.js",
"js/components/AppShell.js",
"js/components/GalaxyModal.js",
"js/components/GalaxySection.js",
"js/components/GalaxyEmbed.js",
"js/components/GalaxyToggleCard.js",
"js/components/SettingTree.js",
"js/components/ParamSections.js",
"js/components/ManeuverCard.js",
"js/components/WheelControls.js",
"js/components/BluetoothPanel.js",
"js/components/DevModeBanner.js",
"js/composables.js",
"js/views/Home.js",
"js/views/Settings.js",
"js/views/Tools.js",
"js/views/Recordings.js",
"js/views/Logs.js",
"js/views/Tuning.js",
"js/views/Navigation.js",
"js/views/Vehicle.js",
"js/views/SystemTools.js",
]
for rel in required:
assert (UI_ROOT / rel).is_file(), f"missing new-UI file: {rel}"
def test_ui_index_wires_vue_and_mount_point():
index = _read("index.html")
assert 'id="galaxy-app"' in index
assert 'src="/assets/mobile/js/app.js"' in index
assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index
def test_ui_uses_same_backend_endpoints():
settings = _read("js/views/Settings.js")
params = _read("js/params.js")
api = _read("js/api.js")
# Settings fetches the exact same layout JSON + params API the original UI used.
assert '/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in settings or \
'/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in api
assert '"/api/params/all"' in api
assert '"/api/params"' in api
assert '"/api/params/defaults"' in api
def test_ui_ports_developer_mode_gating():
params = _read("js/params.js")
assert "countAdvancedHiddenByDeveloperMode" in params
assert "isSettingVisible" in params
assert "isAdvancedHiddenByDeveloperMode" in params
def test_ui_restores_hierarchical_sub_toggle_rendering():
# Children must nest under parents via the recursive SettingTree, gated on
# the parent being enabled AND expanded (classic renderSettingTree contract).
params = _read("js/params.js")
settings = _read("js/views/Settings.js")
tree = _read("js/components/SettingTree.js")
assert "buildRenderTree" in params
assert "isParamEnabledForChildren" in params
assert "hasChildParams" in params
assert "SettingTree" in settings
assert '<SettingTree :params="activeSection.params"' in settings
# SettingTree recursively reveals children; subpanels are collapsed by default
# (classic Galaxy behavior) and expand only when the user taps Manage/Close.
assert "showChildren(p)" in tree
assert "enabledForChildren(p) && this.isExpanded(p)" in tree
assert "gx-tree-node--child" in tree
assert "gx-collapse" in tree
# It self-references so arbitrary nesting depth (grandchildren, etc.) works.
assert "<SettingTree" in tree
def test_ui_ports_all_tool_views():
# Endpoint usage may live in a view or in a reusable component it composes.
checks = {
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot", "runTroubleshoot"],
"js/views/Tuning.js": ["GalaxyEmbed", 'src="/tuning"'],
"js/views/Navigation.js": ["getNavigation", "setNavigation"],
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
"js/components/WheelControls.js": ["getWheelControlsStatus"],
"js/components/BluetoothPanel.js": ["getBluetoothStatus"],
}
for rel, endpoints in checks.items():
src = _read(rel)
assert src, f"missing file: {rel}"
for ep in endpoints:
assert ep in src, f"{rel} should use api.{ep}"
vehicle = _read("js/views/Vehicle.js")
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle and "carFeaturesCheck" in vehicle
def test_ui_routes_ported_views_natively_no_classic_fallback():
app = _read("js/app.js")
shell = _read("js/components/AppShell.js")
tools = _read("js/views/Tools.js")
home = _read("js/views/Home.js")
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
assert view in app, f"app.js should register {view}"
# Ported routes must resolve natively in the Vue app (zero /classic redirect).
for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system"]:
assert route in shell, f"AppShell should route {route} natively"
assert route in app, f"app.js should resolve {route} natively"
# Tools grid routes the native categories (Recordings lives in the bottom nav
# and is intentionally absent from the Tools page).
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]:
assert tool in tools, f"Tools grid should route {tool} natively"
# V-ASM Spot Monitor and PiP Side Camera use the classic page-in-page embeds
# instead of the removed native Annotation Tool.
assert "/manage_v_asm" in tools and "/manage_pip_sidecam" in tools
# Unmigrated tools are embedded (ToolEmbed), never a full-page redirect.
assert "return ToolEmbed" in app
assert "ToolEmbed" in app
# Home renders the classic dashboard as a shared page-in-page embed.
assert 'src="/classic"' in home and "GalaxyEmbed" in home
# Neither the shell, tools grid, nor home tiles ever redirect out of the UI.
for src in [shell, tools, home, _read("js/views/ToolEmbed.js"), _read("js/store.js")]:
assert "window.location.href" not in src
def test_ui_embeds_unmigrated_tools_inapp():
# All page-in-page embeds go through the single shared GalaxyEmbed component.
embed_comp = _read("js/components/GalaxyEmbed.js")
assert "iframe" in embed_comp and "gx-embed__frame" in embed_comp
tool = _read("js/views/ToolEmbed.js")
assert "GalaxyEmbed" in tool and "forward-nav" in tool
store = _read("js/store.js")
assert "toolHref" in store and "/embed?src=" in store
shell = _read("js/components/AppShell.js")
assert "toolHref" in shell
# Embed mode tags the iframe src so the classic SPA hides its sidebar/menu.
assert "embedded=1" in embed_comp
classic_index = (REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html").read_text(encoding="utf-8")
assert 'has("embedded")' in classic_index and 'classList.add("embedded")' in classic_index
assert "window.self !== window.top" in classic_index
def test_ui_numeric_toggles_are_sliders_with_default():
card = _read("js/components/GalaxyToggleCard.js")
# All numeric params render as a slider (no more +/- stepper or bare 0 button).
assert "isSlider() { return this.isNumeric }" in card
assert 'type="range"' in card
assert "onSliderInput" in card and "onSliderCommit" in card
# A Default (reset-to-stock) button is present for numerics.
assert "resetToDefault" in card
# The stepper/number-input UI is gone.
assert 'type="number"' not in card
assert "{{ stepLabel() }}" not in card
assert 'title="Set to zero"' not in card
def test_ui_centralizes_api_and_uses_composables():
api = _read("js/api.js")
composables = _read("js/composables.js")
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
src = _read(f"js/views/{view}.js")
# Views must not issue raw fetch() / hand-rolled polling / SSE.
assert "fetch(" not in src.replace("api.", ""), f"{view} should not use raw fetch()"
# Shared polling + log streaming live in composables, not duplicated in views.
assert "usePolling" in composables
assert "useLogStream" in composables
assert "usePolling" in _read("js/components/ManeuverCard.js")
assert "usePolling" in _read("js/components/WheelControls.js")
assert "useLogStream" in _read("js/views/Logs.js")
def test_ui_schema_driven_param_engine_reused():
# Tuning is a page-in-page embed of the classic /tuning SPA (which owns its
# own tuning UI). Vehicle is a hub of Controllers/Bluetooth/Features — all
# toggles live in the dedicated Settings view, so it must NOT render toggles.
tuning = _read("js/views/Tuning.js")
embed_comp = _read("js/components/GalaxyEmbed.js")
assert 'src="/tuning"' in tuning and "GalaxyEmbed" in tuning, "Tuning should embed the classic tuning SPA"
assert "embedded=1" in embed_comp
vehicle = _read("js/views/Vehicle.js")
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle
assert "GalaxySection" in vehicle
engine = _read("js/components/ParamSections.js")
assert "SettingTree" in engine
assert "isSettingVisible" in engine
def test_ui_eliminates_slider_toggle_flicker():
card = _read("js/components/GalaxyToggleCard.js")
css = _read("css/material.css")
# Optimistic local preview + interacting guard. The value only commits when
# the drag/keyboard interaction is RELEASED, so holding never drops it.
assert "preview" in card
assert "interacting" in card
assert "onSliderCommit" in card
assert "flushSlider" in card
# No mid-drag auto-commit timer: holding still must NOT release/lock.
assert "commitTimer" not in card
assert "setTimeout" not in card
# Release (change) and blur (keyboard) both flush the commit.
assert "interacting = false" in card
assert "onSliderBlur" in card
assert "@blur=\"onSliderBlur\"" in card
assert "currentValue() { return this.preview !== undefined ? this.preview : this.value }" in card
# Manage/Close affordance for parent toggles with nested children.
assert "manageable" in card
assert "manageOpen" in card
assert "gx-manage-btn" in card
# Nested indentation / expand animation styling present.
assert ".gx-tree-node--child" in css
assert ".gx-manage-btn" in css
assert ".gx-tree-children" in css
def test_ui_developer_mode_banner_offers_unlock():
banner = _read("js/components/DevModeBanner.js")
assert "Enable Developer Mode" in banner
assert 'navigate("/settings/developer")' in banner
assert "advanced setting" in banner
def test_ui_has_bottom_navigation_and_drawer():
shell = _read("js/components/AppShell.js")
# Exactly one navigation affordance: liquid-glass bottom nav (mobile) OR the
# drawer hamburger (desktop). Mobile shows a back button instead.
assert "liquid-glass-nav" in shell
assert "nav-item" in shell
assert "gx-menu-btn" in shell
assert "gx-back-btn" in shell
assert "goBack" in shell or "back()" in shell
assert "gx-drawer" in shell
assert "gx-appbar" in shell
assert "Search settings" in shell
def test_ui_search_visible_on_mobile_and_content_full_width():
css = _read("css/material.css")
# Search must NOT be hidden on mobile (regression: it was display:none <600px).
assert ".gx-appbar__search" in css
# A single breakpoint picks mobile (bottom nav + back) vs desktop (drawer).
assert ".liquid-glass-nav { display: flex; }" in css
assert ".gx-menu-btn { display: none; }" in css
assert ".gx-back-btn { display: inline-flex; }" in css
# Content + embedded tools fill the available width (no 760px cap).
assert "max-width: none" in css
# Liquid Glass styling is present.
assert "--glass-bg" in css
assert "backdrop-filter" in css
def test_ui_glass_nav_single_breakpoint_no_dual_nav():
css = _read("css/material.css")
assert ".liquid-glass-nav" in css
assert "@media (min-width: 768px)" in css
assert ".liquid-glass-nav { display: none; }" in css
assert ".gx-back-btn { display: none; }" in css
assert ".gx-menu-btn { display: inline-flex; }" in css
def test_ui_settings_deep_links_and_dev_mode_updates():
settings = _read("js/views/Settings.js")
# Route is reactive via a computed (not the broken this.store watch), so a
# Developer-Mode navigation updates the page without a refresh.
assert "route() { return store.route }" in settings
assert "route() { this.applyRouteSection() }" in settings
# Selecting a section + opening a subpanel update the URL (deep-linkable).
assert 'navigate("/settings/" + slug)' in settings
assert "?open=" in settings
# Turning Developer Mode on reloads data so newly-visible sections (e.g.
# Favorites) render immediately.
assert "devModeOn() { this.load() }" in settings
def test_ui_galaxy_background_is_css_only_and_lightweight():
index = _read("index.html")
css = _read("css/material.css")
# Background is a single fixed layer, pure CSS (no canvas/WebGL/images).
assert 'id="galaxy-bg"' in index
assert "#galaxy-bg" in css
assert "position: fixed" in css
assert "radial-gradient" in css
# Cheap GPU-friendly transforms + stars; disabled for reduced-motion.
assert "--gx-para-x" in css
assert "prefers-reduced-motion" in css
# App content sits above the background layer.
assert ".gx-app" in css and "z-index: 1" in css
def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
source = GALAXY_PY.read_text(encoding="utf-8")
# The classic Galaxy SPA is the default landing at / (original behaviour).
assert '@app.route("/", methods=["GET"])' in source
assert 'render_template("index.html")' in source
# Classic also stays reachable at /classic (page-in-page embed target).
assert '@app.route("/classic", methods=["GET"])' in source
# The modern Vue UI is served at /mobile (and /ui), not the root.
assert '@app.route("/mobile", methods=["GET"])' in source
assert '@app.route("/ui", methods=["GET"])' in source
assert 'Path(app.static_folder) / "mobile" / "index.html"' in source
def test_ui_manifest_is_valid_pwa_manifest():
manifest = json.loads((UI_ROOT / "manifest.json").read_text(encoding="utf-8"))
assert manifest["display"] == "standalone"
assert manifest["name"]
assert manifest["icons"]
assert manifest["start_url"] == "/mobile/"
def _node_exe():
candidates = [
shutil.which("node"),
"/mnt/c/Program Files/nodejs/node.exe",
]
for path in candidates:
if path and Path(path).exists():
return path
return None
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
def test_ui_ported_param_logic_runs_and_passes(tmp_path):
node = _node_exe()
params_src = _read("js/params.js")
params_dst = tmp_path / "params.mjs"
params_dst.write_text(params_src, encoding="utf-8")
script = tmp_path / "pcheck.mjs"
script.write_text(
"""
import * as P from "./params.mjs"
const sec = { name: "Lateral (Steering)", params: [
{ key: "AlwaysOnLateral", settings_tier: "simple", data_type: "bool" },
{ key: "VASMEnabled", settings_tier: "advanced", data_type: "bool" },
]}
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
assert(P.isSettingVisible(sec, sec.params[0], {}) === true, "simple visible (off)")
assert(P.isSettingVisible(sec, sec.params[1], {}) === false, "advanced hidden (off)")
assert(P.isSettingVisible(sec, sec.params[1], { GalaxyDeveloperMode: true }) === true, "advanced visible (on)")
assert(P.countAdvancedHiddenByDeveloperMode([sec], {}) === 1, "count hidden (off)")
assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }) === 0, "count hidden (on)")
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
console.log("params.js logic OK")
""",
encoding="utf-8",
)
result = subprocess.run([node, str(script)], capture_output=True, text=True)
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
def test_ui_hierarchical_tree_logic_runs_and_passes(tmp_path):
node = _node_exe()
params_src = _read("js/params.js")
(tmp_path / "params.mjs").write_text(params_src, encoding="utf-8")
script = tmp_path / "treecheck.mjs"
script.write_text(
"""
import * as P from "./params.mjs"
const params = [
{ key: "Parent", ui_type: "toggle" },
{ key: "Child", parent_key: "Parent", ui_type: "toggle" },
{ key: "GrandChild", parent_key: "Child", ui_type: "toggle" },
{ key: "Sibling", ui_type: "toggle" },
]
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
// Parent off -> no children rendered.
let tree = P.buildRenderTree(params, {}, {})
assert(tree.length === 2, "parent off shows only roots, got " + tree.length)
assert(tree.map(t => t.param.key).join(",") === "Parent,Sibling", "parent off keys")
// Parent on but collapsed -> still no children.
tree = P.buildRenderTree(params, { Parent: true }, {})
assert(tree.length === 2, "parent on but collapsed hides children")
// Parent on + expanded -> children, but grandchild hidden until Child expanded.
tree = P.buildRenderTree(params, { Parent: true }, { Parent: true })
assert(tree.map(t => t.param.key).join(",") === "Parent,Child,Sibling", "one level deep")
assert(tree[1].depth === 1, "child depth is 1")
// Full expansion -> grandchild at depth 2.
tree = P.buildRenderTree(params, { Parent: true, Child: true }, { Parent: true, Child: true })
const gc = tree.find(t => t.param.key === "GrandChild")
assert(gc && gc.depth === 2, "grandchild nested at depth 2")
assert(tree[0].depth === 0, "root depth is 0")
assert(P.isParamEnabledForChildren({ key: "X" }, { X: true }) === true, "enabled when true")
assert(P.isParamEnabledForChildren({ key: "X" }, { X: false }) === false, "disabled when false")
assert(P.isParamEnabledForChildren({ ui_type: "group" }, {}) === true, "group always enabled")
assert(P.hasChildParams(params, "Parent") === true, "hasChildParams true")
assert(P.hasChildParams(params, "Sibling") === false, "hasChildParams false")
console.log("hierarchy logic OK")
""",
encoding="utf-8",
)
result = subprocess.run([node, str(script)], capture_output=True, text=True)
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"

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