mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-03 14:43:48 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 352b23ddf5 | |||
| c7a3e3297a | |||
| b390513ad1 | |||
| 6f5e267493 | |||
| bb3b1429eb | |||
| 3b4a570564 | |||
| a3bdcf2417 | |||
| f553b8071d | |||
| f7fad2a4d5 | |||
| 5fe8b17467 | |||
| 51d8062c36 | |||
| 5122b8df42 |
+1
-1
@@ -21,7 +21,7 @@ fi
|
||||
export QCOM_PRIORITY=12
|
||||
|
||||
if [ -z "$AGNOS_VERSION" ]; then
|
||||
export AGNOS_VERSION="19.6.16"
|
||||
export AGNOS_VERSION="19.6.17"
|
||||
fi
|
||||
|
||||
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.hyundai import hyundaicanfd, hyundaican
|
||||
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, \
|
||||
KIA_EV6_GT_LINE_LONG_TUNING_TESTING_GROUND_ID
|
||||
from opendbc.car.interfaces import CarControllerBase
|
||||
@@ -777,6 +777,8 @@ class CarController(CarControllerBase):
|
||||
left_lane_warning, right_lane_warning, lka_icon))
|
||||
if self.CP.carFingerprint == CAR.KIA_RAY_EV:
|
||||
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
|
||||
if not self.long_active_ecu:
|
||||
@@ -850,9 +852,6 @@ class CarController(CarControllerBase):
|
||||
)
|
||||
|
||||
# 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 \
|
||||
not self.long_active_ecu and self.CP.carFingerprint != CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and \
|
||||
preserve_stock_canfd_lkas_status(self.CP.carFingerprint)
|
||||
|
||||
@@ -36,6 +36,8 @@ CLASSIC_MEDIA_BUTTON_CARS = frozenset({
|
||||
|
||||
|
||||
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:
|
||||
return "LABEL11", "CC_React", "EMS12", "ACC_ACT", "E_EMS11", "Cruise_Limit_Target"
|
||||
if CP.flags & HyundaiFlags.HYBRID:
|
||||
@@ -142,6 +144,7 @@ class CarState(CarStateBase):
|
||||
self.msg_364 = {}
|
||||
self.lfa_block_msg = {}
|
||||
self.stock_lkas_msg = {}
|
||||
self.lkas12 = {}
|
||||
self.stock_lfa_msg = {}
|
||||
self.stock_lfahda_cluster_msg = {}
|
||||
self.stock_camera_lead_visible = False
|
||||
@@ -347,9 +350,7 @@ class CarState(CarStateBase):
|
||||
|
||||
# cruise state
|
||||
no_scc = bool(self.CP.flags & HyundaiFlags.NON_SCC)
|
||||
if self.CP.carFingerprint == CAR.KIA_RAY_EV:
|
||||
pass
|
||||
elif no_scc:
|
||||
if 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)
|
||||
ret.cruiseState.available = cp.vl[cruise_available_msg][cruise_available_sig] != 0
|
||||
ret.cruiseState.enabled = cp.vl[cruise_enabled_msg][cruise_enabled_sig] != 0
|
||||
@@ -440,6 +441,8 @@ class CarState(CarStateBase):
|
||||
self.lkas11 = {}
|
||||
else:
|
||||
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.steer_state = cp.vl["MDPS12"]["CF_Mdps_ToiActive"] # 0 NOT ACTIVE, 1 ACTIVE
|
||||
if not self.main_cruise_tracking:
|
||||
@@ -722,6 +725,12 @@ class CarState(CarStateBase):
|
||||
("BCM_PO_11", 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:
|
||||
# Steering-wheel media switches are event-driven on the refresh Elantra.
|
||||
msgs.append(("GW_SWRC_PE", 0))
|
||||
@@ -730,7 +739,7 @@ class CarState(CarStateBase):
|
||||
|
||||
parsers = {
|
||||
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:
|
||||
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
|
||||
|
||||
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_Chksum"] = 0
|
||||
|
||||
@@ -102,6 +106,19 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
|
||||
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):
|
||||
dat = packer.make_can_msg(addr, bus, values)[1]
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
address = packer.dbc.name_to_msg["LFA"].address
|
||||
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:
|
||||
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
|
||||
|
||||
control_values = {
|
||||
|
||||
@@ -710,7 +710,7 @@ class TestHyundaiFingerprint:
|
||||
assert parser.vl["LKAS11"]["CF_Lkas_LdwsActivemode"] == 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[2][0x485] = 4
|
||||
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])
|
||||
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, 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,
|
||||
)
|
||||
parser.update([(1, [msg])])
|
||||
|
||||
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_FcwOpt_USM"] == 1
|
||||
assert parser.vl["LKAS11"]["CF_Lkas_FcwOpt_USM"] == 2
|
||||
|
||||
def test_kia_ray_ev_delays_first_lkas11(self):
|
||||
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)
|
||||
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):
|
||||
fingerprint = gen_empty_fingerprint()
|
||||
fingerprint[0][0x391] = 8
|
||||
@@ -864,7 +926,7 @@ class TestHyundaiFingerprint:
|
||||
(CAR.HYUNDAI_ELANTRA_2022_NON_SCC, ("EMS16", "LVR12"), ()),
|
||||
(CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC, ("E_CRUISE_CONTROL", "ELECT_GEAR"), ("EMS16",)),
|
||||
(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):
|
||||
toggles = get_test_toggles()
|
||||
@@ -883,6 +945,26 @@ class TestHyundaiFingerprint:
|
||||
assert not ret.cruiseState.enabled
|
||||
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):
|
||||
class FakeParams:
|
||||
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_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.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN
|
||||
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEERING)
|
||||
@@ -2331,9 +2413,11 @@ class TestHyundaiFingerprint:
|
||||
parser.update([(1, lkas_msgs)])
|
||||
assert parser.can_valid
|
||||
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"]["STEER_REQ"] == 1
|
||||
assert parser.vl["LKAS"]["STEER_MODE"] == 0
|
||||
assert parser.vl["LKAS"]["NEW_SIGNAL_2"] == 0
|
||||
|
||||
CP.openpilotLongitudinalControl = True
|
||||
lfa_parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LFA", 0)], can_bus.ECAN)
|
||||
|
||||
@@ -124,6 +124,7 @@ class HyundaiStarPilotSafetyFlags(IntFlag):
|
||||
class HyundaiStarPilotFlags(IntFlag):
|
||||
SPEED_LIMIT_AVAILABLE = 1
|
||||
MAIN_CRUISE_STATE_TRACKING = 2 ** 2
|
||||
HAS_LKAS12 = 2 ** 9
|
||||
|
||||
|
||||
class HyundaiFlags(IntFlag):
|
||||
|
||||
@@ -240,6 +240,9 @@ class CarInterfaceBase(ABC):
|
||||
if 0x1FA in fingerprint[CAN.ECAN]:
|
||||
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)
|
||||
if fp_ret.redneckCruiseAvailable and params.get_bool("RedneckCruise"):
|
||||
fp_ret.pcmCruiseSpeed = False
|
||||
|
||||
@@ -1500,6 +1500,7 @@ BO_ 913 BCM_PO_11: 8 Vector__XXX
|
||||
|
||||
BO_ 1426 LABEL11: 8 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
|
||||
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 */ \
|
||||
{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 */ \
|
||||
{0x53E, 0, 6, .check_relay = false}, /* LKAS12 replacement after camera advertises it */ \
|
||||
|
||||
#define HYUNDAI_LONG_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;
|
||||
}
|
||||
|
||||
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) {
|
||||
uint8_t chksum = 0;
|
||||
if (msg->addr == 0x386U) {
|
||||
@@ -284,6 +291,10 @@ static bool hyundai_tx_hook(const CANPacket_t *msg) {
|
||||
|
||||
bool tx = true;
|
||||
|
||||
if ((msg->addr == 0x53EU) && !hyundai_has_lkas12) {
|
||||
tx = false;
|
||||
}
|
||||
|
||||
// FCA11: Block any potential actuation. The blended HDA II layout uses
|
||||
// different static fields, but its explicit AEB/FCA request bits stay zero.
|
||||
if (msg->addr == 0x38DU) {
|
||||
@@ -695,6 +706,7 @@ static safety_config hyundai_legacy_init(uint16_t param) {
|
||||
const safety_hooks hyundai_hooks = {
|
||||
.init = hyundai_init,
|
||||
.rx = hyundai_rx_hook,
|
||||
.rx_all = hyundai_rx_all_hook,
|
||||
.tx = hyundai_tx_hook,
|
||||
.get_counter = hyundai_get_counter,
|
||||
.get_checksum = hyundai_get_checksum,
|
||||
@@ -704,6 +716,7 @@ const safety_hooks hyundai_hooks = {
|
||||
const safety_hooks hyundai_legacy_hooks = {
|
||||
.init = hyundai_legacy_init,
|
||||
.rx = hyundai_rx_hook,
|
||||
.rx_all = hyundai_rx_all_hook,
|
||||
.tx = hyundai_tx_hook,
|
||||
.get_counter = hyundai_get_counter,
|
||||
.get_checksum = hyundai_get_checksum,
|
||||
|
||||
@@ -63,6 +63,9 @@ bool hyundai_cancel_button_enable = false;
|
||||
extern bool hyundai_can_refresh_msgs;
|
||||
bool hyundai_can_refresh_msgs = false;
|
||||
|
||||
extern bool hyundai_has_lkas12;
|
||||
bool hyundai_has_lkas12 = false;
|
||||
|
||||
extern bool hyundai_elantra_hev_2024;
|
||||
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_cancel_button_enable = GET_FLAG(param, HYUNDAI_PARAM_CANCEL_BTN_ENABLE);
|
||||
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_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)))
|
||||
|
||||
|
||||
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):
|
||||
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,2 +1,2 @@
|
||||
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.
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 @@
|
||||
DEV-3ebc6b99-DEBUG
|
||||
DEV-a3bdcf24-DEBUG
|
||||
@@ -26,6 +26,7 @@ Usage:
|
||||
Commands:
|
||||
c3 Launch the large 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.
|
||||
replay Build and run replay 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}" "$@"
|
||||
}
|
||||
|
||||
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() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
@@ -588,7 +630,7 @@ main() {
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
c3|c4|onroad|replay|shell|python|pytest)
|
||||
c3|c4|galaxy|onroad|replay|shell|python|pytest)
|
||||
set_host_bucket "shared"
|
||||
acquire_host_lock "${command} $*"
|
||||
;;
|
||||
@@ -629,6 +671,9 @@ main() {
|
||||
c4)
|
||||
launch_c4 "$@"
|
||||
;;
|
||||
galaxy)
|
||||
launch_galaxy "$@"
|
||||
;;
|
||||
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_CALM_JERK = 0.35
|
||||
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_WIDTH = 0.08
|
||||
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0
|
||||
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_WIDTH = 0.10
|
||||
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_JERK = 0.08
|
||||
GENESIS_G70_CURVE_UNWIND_JERK_WIDTH = 0.08
|
||||
GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.36
|
||||
GENESIS_G70_UNWIND_FF_OVERSHOOT = 0.10
|
||||
GENESIS_G70_UNWIND_FF_OVERSHOOT_WIDTH = 0.12
|
||||
GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.28
|
||||
GENESIS_G70_UNWIND_FF_OVERSHOOT = 0.18
|
||||
GENESIS_G70_UNWIND_FF_OVERSHOOT_WIDTH = 0.20
|
||||
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_WIDTH = 3.0
|
||||
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_JERK = 0.15
|
||||
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_START = 70.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_SPEED = 2.5
|
||||
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_WIDTH = 0.5
|
||||
BOLT_2022_2023_LOW_SPEED_CENTER_OUTPUT_SPEED_MAX = 8.2
|
||||
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_ALPHA_MIN = 0.28
|
||||
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) /
|
||||
GENESIS_G70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH)
|
||||
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 *
|
||||
(0.35 + (0.65 * jerk_weight)) * phase_weight)
|
||||
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_ACCEL_RISE_RATE = 4.0
|
||||
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
|
||||
HONDA_CRV_5G_LEAD_FOLLOW_JERK_SCALE = 1.35
|
||||
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():
|
||||
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="honda", carFingerprint="HONDA_CRV_5G")) == 1.35
|
||||
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="other", carFingerprint="OTHER_CAR")) == 1.0
|
||||
|
||||
@@ -7,6 +7,16 @@ import pyray as rl
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.starpilot_status import (
|
||||
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):
|
||||
global _smoothed_steer
|
||||
sm = ui_state.sm
|
||||
border_roundness = get_border_roundness(rect, border_width)
|
||||
|
||||
# 1. Turn Signal and Blind Spot indicators
|
||||
colors = get_traffic_border_colors()
|
||||
@@ -225,11 +227,11 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
|
||||
left_color, right_color = colors
|
||||
if left_color.a > 0:
|
||||
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()
|
||||
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.draw_rectangle_rounded(rect, 0.12, 10, right_color)
|
||||
rl.draw_rectangle_rounded(rect, border_roundness, 10, right_color)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# 2. Steering Torque Border
|
||||
@@ -262,7 +264,7 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
|
||||
else:
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_
|
||||
from openpilot.selfdrive.ui.lib.starpilot_status import (
|
||||
get_screen_edge_color,
|
||||
)
|
||||
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_roundness
|
||||
from openpilot.starpilot.common.favorite_slots import (
|
||||
build_favorite_slot_options,
|
||||
filter_favorite_slot_options,
|
||||
@@ -100,8 +101,9 @@ class StarPilotOnroadView(AugmentedRoadView):
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
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))
|
||||
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)
|
||||
|
||||
# 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):
|
||||
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,
|
||||
rect.width - 2 * border_width, rect.height - 2 * border_width)
|
||||
render_overlay(border_rect, border_width)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import importlib.util
|
||||
import math
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
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
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
lead_indicator_enabled = MODULE.lead_indicator_enabled
|
||||
get_border_roundness = MODULE.get_border_roundness
|
||||
|
||||
|
||||
class FakeParams:
|
||||
@@ -29,6 +32,21 @@ class FakeParams:
|
||||
|
||||
|
||||
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):
|
||||
self.assertTrue(lead_indicator_enabled(FakeParams()))
|
||||
|
||||
|
||||
@@ -193,6 +193,19 @@ body {
|
||||
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 ――― */
|
||||
h1,
|
||||
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 { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
|
||||
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 { 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 { Home } from "/assets/components/home/home.js"
|
||||
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
|
||||
@@ -70,12 +72,15 @@ function Root() {
|
||||
let routes = [
|
||||
createRoute("bluetooth", "/bluetooth", Bluetooth),
|
||||
createRoute("wheel_controls", "/wheel-controls", WheelControls),
|
||||
createRoute("doors", "/manage_doors", DoorControl),
|
||||
createRoute("tsk", "/manage_tsk", TSKManager),
|
||||
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
|
||||
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
|
||||
createRoute("galaxy", "/galaxy", GalaxyPairing),
|
||||
createRoute("navdestination", "/set_navigation_destination", NavDestination),
|
||||
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
|
||||
createRoute("root", "/", Home),
|
||||
createRoute("classicRoot", "/classic", Home),
|
||||
createRoute("routes", "/dashcam_routes", RouteRecordings),
|
||||
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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
Reference in New Issue
Block a user