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 |
+5
-19
@@ -159,21 +159,7 @@ All four files must be updated together.
|
||||
|
||||
## Manifest
|
||||
|
||||
The current test branch uses manifest v25 and requests v25 only. Seed the new
|
||||
manifest from the previous catalog, then replace entries as artifacts are
|
||||
rebuilt with the pinned runtime:
|
||||
|
||||
```bash
|
||||
cp /path/to/model_names_v24.json /path/to/model_names_v25.json
|
||||
```
|
||||
|
||||
The current tinygrad pin is `f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae`, from
|
||||
`openpilot` `origin/master` (`bump tg + TC_MIN_GLOBALS`). StarPilot's
|
||||
multi-model `modeld` remains in place; do not replace it with upstream's
|
||||
single-model `modeld`.
|
||||
|
||||
For the older namespace migration workflow, generate the base manifest after
|
||||
compilation and namespace the release artifacts as v23:
|
||||
Generate the base manifest after compilation, then namespace the release artifacts as v23:
|
||||
|
||||
```bash
|
||||
python3 scripts/model_rebuild_pipeline.py manifest \
|
||||
@@ -189,9 +175,9 @@ python3 scripts/namespace_model_artifacts.py \
|
||||
|
||||
The namespace command changes IDs such as `tr1422` to `tr14223`, renames the
|
||||
compiled and upload-ready files, and writes an ID map. It preserves display
|
||||
names and behavioral versions. The current model manager requests v25 only; the
|
||||
manifest is fetched from `Models/model_names_v25.json`. Devices still running
|
||||
the prior branch continue to request their existing manifest version.
|
||||
names and behavioral versions. The current model manager requests v23 only;
|
||||
the manifest is fetched from `Models/model_names_v23.json`, while v22 remains
|
||||
available for devices that have not updated yet.
|
||||
|
||||
After importing newly compiled sources, normalize the release namespace before
|
||||
copying files into either resource repository:
|
||||
@@ -219,4 +205,4 @@ Compilation validates JIT capture/replay, pickle round-trip, finite outputs, met
|
||||
4. Confirm `driverStateV2` on both supported camera resolutions.
|
||||
5. Test download, selection, deletion, randomization, migration, and fallback in both device UIs and Galaxy.
|
||||
|
||||
The built-in RDF artifact is `selfdrive/modeld/models/driving_tinygrad.pkl`. If migration cannot download the selected v25 artifact, StarPilot switches to that built-in model.
|
||||
The built-in RDF artifact is `selfdrive/modeld/models/driving_tinygrad.pkl`. If migration cannot download the selected v23 artifact, StarPilot switches to that built-in model.
|
||||
|
||||
+1
-1
@@ -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 "$@"
|
||||
;;
|
||||
|
||||
@@ -56,13 +56,14 @@ def build_compile_env(*, supercombo: bool = False) -> dict[str, str]:
|
||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = f"{REPO_ROOT}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(REPO_ROOT)
|
||||
defaults = {
|
||||
"DEBUG": "0",
|
||||
"FLOAT16": "1",
|
||||
"IMAGE": "1" if supercombo else "2",
|
||||
"JIT_BATCH_SIZE": "0",
|
||||
"NOLOCALS": "1",
|
||||
"OPENPILOT_HACKS": "1",
|
||||
}
|
||||
} | ({} if supercombo else {
|
||||
"DEBUG": "0",
|
||||
})
|
||||
for key, default in defaults.items():
|
||||
try:
|
||||
int(str(env.get(key)), 0)
|
||||
|
||||
@@ -31,7 +31,7 @@ OPENPILOT_REPO = "commaai/openpilot"
|
||||
RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
|
||||
HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources")
|
||||
RESOURCE_BRANCH = "Models"
|
||||
MANIFEST_VERSION = "v25"
|
||||
MANIFEST_VERSION = "v24"
|
||||
DEFAULT_BEHAVIOR_VERSION = "v16"
|
||||
DEVICE_ROOT = "/data/openpilot"
|
||||
REPOSITORY_FILE_LIMIT = 100_000_000
|
||||
|
||||
@@ -79,14 +79,14 @@ def test_runtime_scan_excludes_model_weights_but_flags_runtime_code():
|
||||
|
||||
|
||||
def test_update_manifest_replaces_one_entry(tmp_path: Path):
|
||||
manifest = tmp_path / "model_names_v25.json"
|
||||
manifest = tmp_path / "model_names_v24.json"
|
||||
manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n")
|
||||
info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16")
|
||||
path = update_manifest(
|
||||
tmp_path,
|
||||
info,
|
||||
{"size": 123, "sha256": "a" * 64},
|
||||
"v25",
|
||||
"v24",
|
||||
)
|
||||
payload = json.loads(path.read_text())
|
||||
assert len(payload["models"]) == 2
|
||||
|
||||
@@ -253,12 +253,12 @@ GENESIS_G70_FRICTION_CENTER_LAT = 0.28
|
||||
GENESIS_G70_FRICTION_CENTER_LAT_WIDTH = 0.10
|
||||
GENESIS_G70_FRICTION_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,10 +7,6 @@ import struct
|
||||
from openpilot.system.hardware import HARDWARE, TICI
|
||||
os.environ['GMMU'] = '0'
|
||||
os.environ['DEV'] = 'QCOM' if TICI else 'LLVM'
|
||||
try:
|
||||
int(os.getenv('DEBUG', '0'), 0)
|
||||
except ValueError:
|
||||
os.environ['DEBUG'] = '0'
|
||||
from tinygrad.device import Device
|
||||
from tinygrad.tensor import Tensor
|
||||
import time
|
||||
|
||||
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 @@
|
||||
31902b114b7fb8455af694d83333a86a44112b83be662e064ffbd67e8daafe72 driving_tinygrad.pkl
|
||||
a77db33c2e2d6a7570dc2a4a70c2b877429ee8bd9ca5dfeda74b5a41231aaff9 driving_tinygrad.pkl
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from openpilot.starpilot.common.starpilot_utilities import delete_file
|
||||
from openpilot.starpilot.common.starpilot_variables import MODELS_PATH
|
||||
from openpilot.system.hardware.usb import chestnut_firmware_ready
|
||||
|
||||
MANIFEST_CANDIDATES = ("v25",)
|
||||
MANIFEST_CANDIDATES = ("v24",)
|
||||
MODEL_NAMESPACE_SUFFIX = "3"
|
||||
DEFAULT_MODEL_KEY = "rdf43"
|
||||
LOCAL_MODEL_PREFIX = "local-"
|
||||
|
||||
@@ -15,12 +15,12 @@ from openpilot.starpilot.assets.model_manager import MANIFEST_CANDIDATES, ModelM
|
||||
from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT
|
||||
|
||||
|
||||
def test_v25_is_the_only_manifest_candidate():
|
||||
assert MANIFEST_CANDIDATES == ("v25",)
|
||||
def test_v24_is_the_only_manifest_candidate():
|
||||
assert MANIFEST_CANDIDATES == ("v24",)
|
||||
|
||||
|
||||
def test_v25_manifest_is_loaded_from_models_checkout():
|
||||
assert ModelManager._manifest_paths("v25") == ("Models/model_names_v25.json",)
|
||||
def test_v24_manifest_is_loaded_from_models_checkout():
|
||||
assert ModelManager._manifest_paths("v24") == ("Models/model_names_v24.json",)
|
||||
|
||||
|
||||
def test_resource_sources_prefer_huggingface_then_github(monkeypatch):
|
||||
@@ -33,9 +33,9 @@ def test_resource_sources_prefer_huggingface_then_github(monkeypatch):
|
||||
|
||||
|
||||
def test_huggingface_manifest_has_root_and_manifests_fallbacks():
|
||||
assert ModelManager._hf_manifest_paths("v25") == (
|
||||
"model_names_v25.json",
|
||||
"manifests/model_names_v25.json",
|
||||
assert ModelManager._hf_manifest_paths("v24") == (
|
||||
"model_names_v24.json",
|
||||
"manifests/model_names_v24.json",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user