Blueberry Pancakes

This commit is contained in:
firestar5683
2026-09-10 11:02:23 -05:00
parent dd607aa07d
commit 244aa67371
24 changed files with 558 additions and 73 deletions
+18
View File
@@ -18,6 +18,7 @@ from opendbc.car.gm.values import (
AccState,
CanBus,
CruiseButtons,
GM_AUTO_HOLD_CARS,
GMFlags,
SDGM_CAR,
STEER_THRESHOLD,
@@ -68,6 +69,18 @@ def update_auto_hold_drive_timers(in_drive_for_hold: bool, moving_for_hold: bool
return auto_hold_drive_time, one_pedal_drive_time
def is_gm_auto_hold_active(car_fingerprint: str, auto_hold_engaged: bool, in_drive_for_hold: bool,
cruise_available: bool, standstill: bool, gas_pressed: bool) -> bool:
return (
auto_hold_engaged and
car_fingerprint in GM_AUTO_HOLD_CARS and
in_drive_for_hold and
cruise_available and
standstill and
not gas_pressed
)
def update_startup_acc_fault_suppression(car_fingerprint: str, system_power_mode: int,
previous_system_power_mode: int, timer: float,
acc_state: int, friction_brake_unavailable: bool) -> tuple[float, bool]:
@@ -431,6 +444,11 @@ class CarState(CarStateBase):
self.auto_hold_fault_suppression_timer = max(self.auto_hold_fault_suppression_timer - DT_CTRL, 0.0)
ret.accFaulted = False
ret.brakeHoldActive = is_gm_auto_hold_active(
self.CP.carFingerprint, self.auto_hold_engaged, in_drive_for_hold,
ret.cruiseState.available, ret.standstill, ret.gasPressed,
)
if self.CP.enableBsm and not sdgm_non_volt:
ret.leftBlindspot = pt_cp.vl["BCMBlindSpotMonitor"]["LeftBSM"] == 1
ret.rightBlindspot = pt_cp.vl["BCMBlindSpotMonitor"]["RightBSM"] == 1
@@ -11,6 +11,7 @@ from opendbc.car.gm import gmcan
from opendbc.car.gm.carstate import (
CarState as GMCarState,
get_hard_cruise_buttons,
is_gm_auto_hold_active,
update_auto_hold_drive_timers,
update_startup_acc_fault_suppression,
)
@@ -210,6 +211,19 @@ class TestBoltGps:
class TestGMCarState:
@parameterized.expand([
(CAR.BUICK_LACROSSE, True, True, True, True, False, True),
(CAR.CHEVROLET_VOLT, True, True, True, True, False, True),
(CAR.CHEVROLET_BOLT_CC_2017, True, True, True, True, False, False),
(CAR.BUICK_LACROSSE, True, True, True, False, False, False),
(CAR.BUICK_LACROSSE, True, True, True, True, True, False),
])
def test_auto_hold_alert_state_requires_supported_complete_stop(self, car_fingerprint, engaged, in_drive,
cruise_available, standstill, gas_pressed, expected):
assert is_gm_auto_hold_active(
car_fingerprint, engaged, in_drive, cruise_available, standstill, gas_pressed,
) is expected
def test_lacrosse_startup_acc_fault_is_suppressed(self):
timer, suppressed = update_startup_acc_fault_suppression(
CAR.BUICK_LACROSSE, 2, 0, 0.0, 3, False,
@@ -16,6 +16,7 @@ from opendbc.car.hyundai.values import HyundaiFlags, HyundaiSafetyFlags, Hyundai
from opendbc.car.interfaces import CarControllerBase
from opendbc.car.vehicle_model import VehicleModel
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import get_hyundai_canfd_scc_jerk_limits
from openpilot.starpilot.common.testing_grounds import testing_ground
VisualAlert = structs.CarControl.HUDControl.VisualAlert
@@ -862,7 +863,6 @@ class CarController(CarControllerBase):
longitudinal_active = bool(self.long_active_ecu and getattr(CC, "longActive", False))
lfa_status_cars = (
CAR.HYUNDAI_IONIQ_6,
CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN,
CAR.KIA_EV6,
)
lfa_longitudinal_active = self.CP.openpilotLongitudinalControl \
@@ -896,7 +896,8 @@ class CarController(CarControllerBase):
if angle_lkas_alt:
steering_msg_active = bool(steering_msg_active and drive_gear)
angle_lkas_alt_standstill_handoff = bool(getattr(CS.out, "standstill", False) and not CC.latActive)
forward_stock_lkas = self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR and angle_lkas_alt and (
forward_stock_lkas = (self.CP.carFingerprint in CANFD_ANGLE_LONGITUDINAL_CAR or
self.CP.carFingerprint == CAR.KIA_SPORTAGE_HEV_2026) and angle_lkas_alt and (
angle_lkas_alt_standstill_handoff or not (drive_gear and (CC.latActive or CC.enabled))
)
preserve_stock_lfa_status = preserve_stock_canfd_lfa_status(self.CP.carFingerprint)
@@ -1023,7 +1024,11 @@ class CarController(CarControllerBase):
CC.rightBlinker))
if self.frame % 2 == 0:
if self.CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN:
acc_kwargs = {}
scc_jerk_limits = get_hyundai_canfd_scc_jerk_limits(self.CP)
acc_kwargs = {
"jerk_upper": scc_jerk_limits[0],
"jerk_lower": scc_jerk_limits[1],
}
else:
lead_visible, lead_distance, lead_rel_speed = self._get_canfd_scc_lead_state(CC, CS, now_nanos)
acc_kwargs = {
@@ -2555,6 +2555,7 @@ class TestHyundaiFingerprint:
"DAMP_FACTOR": 100,
}
cc = SimpleNamespace(enabled=True, latActive=True,
longActive=False,
actuators=SimpleNamespace(longControlState=LongCtrlState.off),
leftBlinker=False, rightBlinker=False,
hudControl=SimpleNamespace())
@@ -2588,7 +2589,7 @@ class TestHyundaiFingerprint:
cc.hudControl, cs, cc, get_test_toggles(), lka_icon=2, lfa_icon=2)
steering_names = [(controller.packer.dbc.addr_to_msg[addr].name, bus) for addr, _, bus in inactive_msgs
if controller.packer.dbc.addr_to_msg[addr].name in ("LFA", "LKAS")]
assert steering_names == [("LFA", can_bus.ECAN), ("LKAS", can_bus.ACAN)]
assert steering_names == [("LKAS", can_bus.ACAN)]
controller.frame = 1
cc.longActive = True
@@ -2755,7 +2756,7 @@ class TestHyundaiFingerprint:
assert len([msg for msg in msgs if msg[0] == 0x110]) == expected_lkas_msgs
@pytest.mark.parametrize("standstill", [False, True])
def test_sportage_angle_lkas_alt_publishes_inactive_status(self, standstill):
def test_sportage_angle_lkas_alt_forwards_stock_status_when_inactive(self, standstill):
CP = CarParams.new_message()
CP.carFingerprint = CAR.KIA_SPORTAGE_HEV_2026
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ANGLE_STEERING |
@@ -2763,7 +2764,6 @@ class TestHyundaiFingerprint:
CP.openpilotLongitudinalControl = False
controller = CarController(DBC[CP.carFingerprint], CP)
can_bus = CanBus(CP)
cc = SimpleNamespace(enabled=False, latActive=False,
actuators=SimpleNamespace(longControlState=LongCtrlState.off),
leftBlinker=False, rightBlinker=False, hudControl=SimpleNamespace())
@@ -2773,16 +2773,7 @@ class TestHyundaiFingerprint:
msgs = controller.create_canfd_msgs(0, False, 0.0, 0.0, 0.0, 0.0, False, cc.hudControl, cs, cc,
get_test_toggles(), lka_icon=1, lfa_icon=1)
lkas_msgs = [msg for msg in msgs if msg[0] == 0x110]
assert len(lkas_msgs) == 1
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS_ALT", 0)], can_bus.ACAN)
parser.update([(1, lkas_msgs)])
assert parser.can_valid
assert parser.vl["LKAS_ALT"]["LKA_ICON"] == 1
assert parser.vl["LKAS_ALT"]["LKA_SysIndReq"] == 1
assert parser.vl["LKAS_ALT"]["LKA_RcgSta"] == 0
assert parser.vl["LKAS_ALT"]["LKAS_ANGLE_ACTIVE"] == 1
assert not [msg for msg in msgs if msg[0] in (0x110, 0x12A)]
def test_ev9_inactive_angle_steering_does_not_suppress_stock_lfa(self):
CP = CarParams.new_message()
@@ -222,12 +222,14 @@ class CarController(CarControllerBase):
self.angle_reclaim_frames = 0
self.angle_reclaim_start_angle = 0.0
def _angle_manual_handoff(self, CS, lat_active):
def _angle_manual_handoff(self, CS, lat_active, use_steering_pressed=False):
if not lat_active:
self._reset_angle_handoff()
return False
driver_override = self._update_angle_driver_override(CS)
if use_steering_pressed:
driver_override = driver_override or getattr(CS.out, "steeringPressed", False)
if driver_override:
self.angle_handoff_active = True
self.angle_override_hold_frames = _ANGLE_OVERRIDE_HOLD_FRAMES
@@ -325,7 +327,9 @@ class CarController(CarControllerBase):
lkas_available = CC.latActive and (not mads_only or mads_only_ok) and \
CS.out.gearShifter == structs.CarState.GearShifter.drive and not CS.out.standstill
manual_handoff = self._angle_manual_handoff(CS, CC.latActive)
manual_handoff = self._angle_manual_handoff(
CS, CC.latActive, use_steering_pressed=self.CP.carFingerprint == CAR.SUBARU_OUTBACK_2023,
)
lkas_active = lkas_available and not manual_handoff
if lkas_active and not self.angle_lkas_active:
@@ -408,6 +412,11 @@ class CarController(CarControllerBase):
return subarucan.create_steering_control(self.packer, apply_torque, apply_steer_req)
def _lkas_status_active(self, CC):
if self.CP.carFingerprint == CAR.SUBARU_OUTBACK_2023:
return self.angle_lkas_active
return CC.latActive
def update(self, CC, CS, now_nanos, starpilot_toggles):
actuators = CC.actuators
hud_control = CC.hudControl
@@ -484,7 +493,7 @@ class CarController(CarControllerBase):
CC.longActive, hud_control.leadVisible,
self.status_bus))
can_sends.append(subarucan.create_es_lkas_state(self.packer, self.frame // 10, CS.es_lkas_state_msg, CC.latActive, hud_control.visualAlert,
can_sends.append(subarucan.create_es_lkas_state(self.packer, self.frame // 10, CS.es_lkas_state_msg, self._lkas_status_active(CC), hud_control.visualAlert,
hud_control.leftLaneVisible, hud_control.rightLaneVisible,
hud_control.leftLaneDepart, hud_control.rightLaneDepart, self.status_bus))
@@ -744,10 +744,10 @@ def test_ascent_angle_controller_blocks_parking_lot_aol_engagement():
assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Output"] == pytest.approx(CS.out.steeringAngleDeg)
def test_lkas_hud_state_uses_lateral_active():
def test_lkas_hud_state_uses_outback_angle_request_state():
update_source = inspect.getsource(CarController.update)
assert "create_es_lkas_state(self.packer, self.frame // 10, CS.es_lkas_state_msg, CC.latActive" in update_source
assert "create_es_lkas_state(self.packer, self.frame // 10, CS.es_lkas_state_msg, self._lkas_status_active(CC)" in update_source
assert "create_es_lkas_state(self.packer, self.frame // 10, CS.es_lkas_state_msg, CC.enabled" not in update_source
@@ -765,3 +765,38 @@ def test_lkas_hud_active_bit_follows_lateral_state(enabled, expected):
assert parser.can_valid
assert parser.vl["ES_LKAS_State"]["LKAS_ACTIVE"] == expected
def test_outback_manual_steering_releases_angle_request_before_lkas_fault():
CP = CarInterface.get_non_essential_params(CAR.SUBARU_OUTBACK_2023)
controller = CarController({}, CP)
CC = SimpleNamespace(
enabled=False,
latActive=True,
actuators=SimpleNamespace(steeringAngleDeg=-225.0),
)
CS = SimpleNamespace(out=SimpleNamespace(
vEgoRaw=0.9,
steeringAngleDeg=-57.0,
steeringRateDeg=-45.0,
steeringTorque=-127.0,
steeringPressed=True,
gearShifter=structs.CarState.GearShifter.drive,
standstill=False,
))
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("ES_LKAS_ANGLE", 0)], CanBus.main)
msg = controller.lateral_angle(CC, CS)
parser.update([(1, [msg])])
assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 0
assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Output"] == pytest.approx(CS.out.steeringAngleDeg)
assert not controller._lkas_status_active(CC)
def test_other_angle_cars_keep_lateral_status_behavior():
CP = CarInterface.get_non_essential_params(CAR.SUBARU_CROSSTREK_2025)
controller = CarController({}, CP)
controller.angle_lkas_active = False
assert controller._lkas_status_active(SimpleNamespace(latActive=True))
+4 -2
View File
@@ -60,6 +60,7 @@ static bool gm_panda_3d1_sched = false;
static bool gm_panda_paddle_sched = false;
static bool gm_bolt_2022_pedal = false;
static bool gm_alt_brake = false;
static bool gm_volt_cc_gateway = false;
static bool gm_volt_auto_hold = false;
static bool gm_volt_one_pedal = false;
@@ -261,7 +262,8 @@ static void gm_rx_hook(const CANPacket_t *msg) {
}
if ((msg->addr == 0xF1U) && gm_alt_brake) {
brake_pressed = msg->data[1] >= 6U;
const uint8_t brake_threshold = gm_volt_cc_gateway ? 21U : 6U;
brake_pressed = msg->data[1] >= brake_threshold;
}
if ((msg->addr == 0xC9U) && (gm_hw == GM_CAM) && !gm_force_brake_c9) {
@@ -720,7 +722,7 @@ static safety_config gm_init(uint16_t param) {
gm_cc_long = GET_FLAG(param, GM_PARAM_CC_LONG);
gm_has_acc = !GET_FLAG(param, GM_PARAM_NO_ACC);
gm_pedal_long = GET_FLAG(param, GM_PARAM_PEDAL_LONG);
const bool gm_volt_cc_gateway = GET_FLAG(param, GM_PARAM_VOLT_CC_GATEWAY) && gm_no_camera && !gm_pedal_long && !gm_has_acc;
gm_volt_cc_gateway = GET_FLAG(param, GM_PARAM_VOLT_CC_GATEWAY) && gm_no_camera && !gm_pedal_long && !gm_has_acc;
enable_gas_interceptor = GET_FLAG(param, GM_PARAM_PEDAL_INTERCEPTOR);
gm_force_ascm = GET_FLAG(param, GM_PARAM_HW_ASCM_LONG);
gm_force_brake_c9 = GET_FLAG(param, GM_PARAM_FORCE_BRAKE_C9);
@@ -654,6 +654,31 @@ class TestGmCcLongitudinalNoCameraSafety(TestGmCcLongitudinalSafety):
self.safety.init_tests()
def test_gm_volt_cc_gateway_brake_threshold_matches_carstate():
safety = libsafety_py.libsafety
safety.set_safety_hooks(
CarParams.SafetyModel.gm,
GMSafetyFlags.FLAG_GM_NO_CAMERA |
GMSafetyFlags.FLAG_GM_NO_ACC |
GMSafetyFlags.FLAG_GM_CC_LONG |
GMSafetyFlags.FLAG_GM_VOLT_CC_GATEWAY,
)
safety.init_tests()
safety.set_controls_allowed(True)
cruise = common.make_msg(0, 0x3D1, 8, bytes([0, 0, 0, 0, 0x80, 0, 0, 0]))
safety.safety_rx_hook(cruise)
assert safety.get_controls_allowed()
noisy_brake = libsafety_py.make_CANPacket(0xF1, 0, b"\x34\x06\x05\x40\x00\x00")
safety.safety_rx_hook(noisy_brake)
assert safety.get_controls_allowed()
pressed_brake = libsafety_py.make_CANPacket(0xF1, 0, b"\x34\x15\x05\x40\x00\x00")
safety.safety_rx_hook(pressed_brake)
assert not safety.get_controls_allowed()
class TestGmCcLongitudinalPandaSchedSafety(TestGmCcLongitudinalSafety):
FWD_BLACKLISTED_ADDRS = {2: [0x180, 0x370], 0: [0x184, 0x3D1]}
INTERCEPTOR_GAS_PRESSED = 596
+13
View File
@@ -249,6 +249,19 @@ void ignition_can_hook(CANPacket_t *msg) {
ignition_can_cnt = 0U;
}
// Volkswagen MEB exception
if ((msg->addr == 0x3C0U) && (len == 4)) {
int counter = msg->data[1] & 0xFU;
static int prev_counter_vw_meb = -1;
if ((counter == ((prev_counter_vw_meb + 1) % 16)) && (prev_counter_vw_meb != -1)) {
// Klemmen_Status_01->ZAS_Kl_15
ignition_can = ((msg->data[2] >> 1) & 1U) != 0U;
ignition_can_cnt = 0U;
}
prev_counter_vw_meb = counter;
}
}
}
+6
View File
@@ -25,6 +25,12 @@ typedef struct {
ffi.cdef("""
int set_safety_hooks(uint16_t mode, uint16_t param);
void ignition_can_hook(CANPacket_t *msg);
void set_ignition_can_for_test(bool enabled);
bool get_ignition_can_for_test(void);
void set_ignition_can_cnt_for_test(uint32_t cnt);
uint32_t get_ignition_can_cnt_for_test(void);
""")
ffi.cdef("""
+16
View File
@@ -24,5 +24,21 @@ can_ring *tx1_q = &can_tx1_q;
can_ring *tx2_q = &can_tx2_q;
can_ring *tx3_q = &can_tx3_q;
void set_ignition_can_for_test(bool enabled) {
ignition_can = enabled;
}
bool get_ignition_can_for_test(void) {
return ignition_can;
}
void set_ignition_can_cnt_for_test(uint32_t cnt) {
ignition_can_cnt = cnt;
}
uint32_t get_ignition_can_cnt_for_test(void) {
return ignition_can_cnt;
}
#include "comms_definitions.h"
#include "can_comms.h"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
import unittest
from panda.tests.libpanda import libpanda_py
lpp = libpanda_py.libpanda
def volkswagen_meb_ignition_msg(counter, ignition, bus=0, length=4):
dat = bytearray(length)
if length >= 2:
dat[1] = counter & 0xF
if length >= 3:
dat[2] = int(ignition) << 1
return libpanda_py.make_CANPacket(0x3C0, bus, dat)
class TestIgnitionCanHook(unittest.TestCase):
def test_volkswagen_meb_ignition(self):
lpp.set_ignition_can_for_test(False)
lpp.set_ignition_can_cnt_for_test(123)
# A single frame is insufficient: the rolling counter must first be established.
lpp.ignition_can_hook(volkswagen_meb_ignition_msg(7, True))
self.assertFalse(lpp.get_ignition_can_for_test())
self.assertEqual(lpp.get_ignition_can_cnt_for_test(), 123)
lpp.ignition_can_hook(volkswagen_meb_ignition_msg(8, True))
self.assertTrue(lpp.get_ignition_can_for_test())
self.assertEqual(lpp.get_ignition_can_cnt_for_test(), 0)
# A duplicate counter must not change ignition state.
lpp.set_ignition_can_cnt_for_test(123)
lpp.ignition_can_hook(volkswagen_meb_ignition_msg(8, False))
self.assertTrue(lpp.get_ignition_can_for_test())
self.assertEqual(lpp.get_ignition_can_cnt_for_test(), 123)
lpp.ignition_can_hook(volkswagen_meb_ignition_msg(9, False))
self.assertFalse(lpp.get_ignition_can_for_test())
self.assertEqual(lpp.get_ignition_can_cnt_for_test(), 0)
if __name__ == "__main__":
unittest.main()
@@ -23,6 +23,8 @@ HONDA_ACCORD_LOW_SPEED_STOP_MAX_LEAD_SPEED = 1.0
HONDA_ACCORD_STANDSTILL_GUARD_MAX_EGO_SPEED = 0.25
HYUNDAI_ELANTRA_LEAD_FOLLOW_JERK_SCALE = 1.25
GENESIS_GV70_ELECTRIFIED_LEAD_FOLLOW_JERK_SCALE = 1.75
GENESIS_GV70_ELECTRIFIED_SCC_JERK_UPPER = 1.5
GENESIS_GV70_ELECTRIFIED_SCC_JERK_LOWER = 2.0
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
@@ -524,6 +526,12 @@ def get_lead_follow_jerk_scale(CP):
return 1.0
def get_hyundai_canfd_scc_jerk_limits(CP):
if str(getattr(CP, "carFingerprint", "")) == "GENESIS_GV70_ELECTRIFIED_1ST_GEN":
return GENESIS_GV70_ELECTRIFIED_SCC_JERK_UPPER, GENESIS_GV70_ELECTRIFIED_SCC_JERK_LOWER
return None
def get_honda_accord_lead_departure_tune(CP):
if CP.brand == "honda" and str(CP.carFingerprint) == "HONDA_ACCORD":
return (
@@ -5,7 +5,10 @@ from types import SimpleNamespace
from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.controls.starpilot_planner import StarPilotPlanner, get_force_stop_jerk_scale
from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import get_lead_follow_jerk_scale
from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import (
get_hyundai_canfd_scc_jerk_limits,
get_lead_follow_jerk_scale,
)
import openpilot.starpilot.controls.starpilot_planner as starpilot_planner_module
@@ -48,6 +51,14 @@ def test_lead_follow_jerk_scale_is_platform_specific():
assert get_lead_follow_jerk_scale(SimpleNamespace(brand="other", carFingerprint="OTHER_CAR")) == 1.0
def test_genesis_gv70_scc_jerk_limits_are_platform_specific():
gv70 = SimpleNamespace(brand="hyundai", carFingerprint="GENESIS_GV70_ELECTRIFIED_1ST_GEN")
other = SimpleNamespace(brand="hyundai", carFingerprint="HYUNDAI_IONIQ_6")
assert get_hyundai_canfd_scc_jerk_limits(gv70) == (1.5, 2.0)
assert get_hyundai_canfd_scc_jerk_limits(other) is None
def make_sm(planner, *, frame: int, v_ego: float, left_blinker: bool, right_blinker: bool = False, standstill: bool = False):
return FakeSM(frame, {
"radarState": SimpleNamespace(
+10 -5
View File
@@ -265,6 +265,15 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 1.5)
def brake_hold_alert(CP, *_args) -> Alert:
alert_text = "Car in Auto Hold mode" if CP.brand == "gm" else "Press Resume to Exit Brake Hold"
return Alert(
alert_text,
"",
AlertStatus.userPrompt, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.none, .2)
def speed_limit_changed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality, starpilot_toggles: SimpleNamespace) -> Alert:
return Alert(
"Speed limit changed",
@@ -802,11 +811,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
},
EventName.brakeHold: {
ET.WARNING: Alert(
"Press Resume to Exit Brake Hold",
"",
AlertStatus.userPrompt, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.none, .2),
ET.WARNING: brake_hold_alert,
},
EventName.parkBrake: {
+3 -1
View File
@@ -44,6 +44,7 @@ FORD_MANUAL_TURN_LATCH_CARS = frozenset({
CAR.FORD_MUSTANG_MACH_E_MK1,
})
MANUAL_TURN_ENTRY_ANGLE_DEG = 12.0
MANUAL_TURN_RELEASE_ANGLE_DEG = 12.0
MANUAL_TURN_RECOVERY_SECONDS = 0.25
@@ -207,7 +208,8 @@ class FordLateralController:
self.manual_turn_recovery_timer = 0.0
return False
if CS.out.steeringPressed or blinker_direction != 0.0:
if (CS.out.steeringPressed or blinker_direction != 0.0 or
abs(CS.out.steeringAngleDeg) > MANUAL_TURN_RELEASE_ANGLE_DEG):
self.manual_turn_recovery_timer = 0.0
else:
self.manual_turn_recovery_timer += STEER_DT
+49
View File
@@ -208,6 +208,55 @@ def test_mach_e_signaled_manual_turn_yields_until_inputs_settle(controller):
assert result.curvature > 0.0
def test_mach_e_manual_turn_waits_for_wheel_to_unwind(controller):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
CC = SimpleNamespace(latActive=True)
actuators = SimpleNamespace(curvature=0.006)
assert not controller.update(CC, car_state(
steering_pressed=True, steering_angle=-30.0, steering_torque=-2.0,
right_blinker=True), actuators).active
for _ in range(8):
result = controller.update(CC, car_state(steering_angle=-35.0), actuators)
assert not result.active
for _ in range(4):
result = controller.update(CC, car_state(steering_angle=-10.0), actuators)
assert not result.active
result = controller.update(CC, car_state(steering_angle=-10.0), actuators)
assert result.active
def test_mach_e_left_manual_turn_waits_for_wheel_to_unwind(controller):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
CC = SimpleNamespace(latActive=True)
actuators = SimpleNamespace(curvature=-0.006)
assert not controller.update(CC, car_state(
steering_pressed=True, steering_angle=30.0, steering_torque=2.0,
left_blinker=True), actuators).active
for _ in range(8):
result = controller.update(CC, car_state(steering_angle=35.0), actuators)
assert not result.active
for _ in range(4):
result = controller.update(CC, car_state(steering_angle=10.0), actuators)
assert not result.active
result = controller.update(CC, car_state(steering_angle=10.0), actuators)
assert result.active
def test_non_mach_e_signaled_turn_does_not_latch(controller):
CC = SimpleNamespace(latActive=True)
result = controller.update(CC, car_state(
steering_pressed=True, steering_angle=30.0, steering_torque=2.0,
left_blinker=True), SimpleNamespace(curvature=-0.006))
assert result.active
def test_mach_e_opposite_blinker_correction_does_not_start_manual_turn(controller):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
@@ -3600,9 +3600,9 @@
},
{
"key": "GMAutoHold",
"label": "Volt Auto Hold",
"description": "Hold the car at a stop on supported non-CC-only Chevy Volts until the gas pedal is pressed.",
"picker_description": "Holds Chevy Volt brakes until the gas pedal is pressed.",
"label": "GM Auto Hold",
"description": "Hold supported GM vehicles at a stop until the gas pedal is pressed.",
"picker_description": "Holds supported GM vehicle brakes until the gas pedal is pressed.",
"data_type": "bool",
"ui_type": "toggle",
"settings_tier": "simple"
@@ -1,6 +1,7 @@
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
import { api } from "../api.js"
import { usePolling } from "../composables.js"
import { languageState, setLanguage, t } from "../i18n.js"
const NAV = {
recordings: [
@@ -37,7 +38,7 @@ export const AppShell = {
},
computed: {
online() { return store.online },
statusLabel() { return store.online ? store.deviceStatus : "Offline" },
statusLabel() { return store.online ? t(store.deviceStatus, store.deviceStatus) : t("Offline") },
isLight() { return store.theme === "light" },
drawerOpen: {
get() { return store.drawerOpen },
@@ -57,6 +58,7 @@ export const AppShell = {
},
},
methods: {
tr(key, fallback = key) { return t(key, fallback) },
closeDrawer() { store.drawerOpen = false },
back() { goBack() },
async refreshStatus() {
@@ -69,6 +71,14 @@ export const AppShell = {
store.online = false
}
},
async loadLanguage() {
try {
const values = await api.getParams()
setLanguage(values?.LanguageSetting || languageState.code || "en")
} catch (e) {
setLanguage(languageState.code || "en")
}
},
clearSearch() {
store.search = ""
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
@@ -89,6 +99,7 @@ export const AppShell = {
},
},
created() {
this.loadLanguage()
this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 })
this.statusPoll.start()
},
@@ -98,21 +109,21 @@ export const AppShell = {
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">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" :aria-label="tr('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">
<button type="button" class="gx-icon-btn gx-menu-btn" :aria-label="tr('Menu')" @click="store.drawerOpen = true">
<i class="bi bi-list"></i>
</button>
<span class="gx-appbar__home" role="button" tabindex="0"
aria-label="Galaxy home" @click="goHome" @keydown.enter="goHome" @keydown.space.prevent="goHome">
<span class="gx-appbar__title">Galaxy</span>
:aria-label="tr('Galaxy home')" @click="goHome" @keydown.enter="goHome" @keydown.space.prevent="goHome">
<span class="gx-appbar__title">{{ tr("Galaxy") }}</span>
</span>
<div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles..."
v-model="search" aria-label="Search toggles" />
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" :placeholder="tr('Search toggles...')"
v-model="search" :aria-label="tr('Search toggles')" />
<button v-if="search" type="button" class="gx-search-clear" :aria-label="tr('Clear search')" @click="clearSearch">
<i class="bi bi-x"></i>
</button>
</div>
@@ -123,8 +134,8 @@ export const AppShell = {
</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">
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? tr('Switch to dark mode') : tr('Switch to light mode')"
:title="isLight ? tr('Dark mode') : tr('Light mode')" @click="themeToggle">
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
</button>
</header>
@@ -135,24 +146,24 @@ export const AppShell = {
<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>
<span class="gx-drawer-title">{{ tr("Galaxy") }}</span>
</div>
<div class="gx-nav-section">
<div class="gx-nav-section__title">Main</div>
<div class="gx-nav-section__title">{{ tr("Main") }}</div>
<a class="gx-nav-item" :class="{ active: isActive('/') }" @click.prevent="navTo('/')">
<i class="bi bi-house-fill"></i><span>Home</span>
<i class="bi bi-house-fill"></i><span>{{ tr("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>
<i class="bi bi-toggle-on"></i><span>{{ tr("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>
<i class="bi bi-tools"></i><span>{{ tr("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>
<div class="gx-nav-section__title">{{ tr(section === 'recordings' ? 'Recordings' : 'Tools') }}</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>
<i class="bi" :class="link.icon"></i><span>{{ tr(link.name, link.name) }}</span>
</a>
</div>
</aside>
@@ -166,7 +177,7 @@ export const AppShell = {
class="nav-item" :class="{ active: isActive(item.link) }"
@click="bottomNavTo(item)">
<i class="bi" :class="item.icon"></i>
<span>{{ item.name }}</span>
<span>{{ tr(item.name, item.name) }}</span>
</button>
</nav>
</div>
@@ -5,6 +5,7 @@ import {
resolveVehicleUnitParam, stepPrecision,
} from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js"
import { t } from "../i18n.js"
export const GalaxyToggleCard = {
name: "GalaxyToggleCard",
@@ -67,6 +68,7 @@ export const GalaxyToggleCard = {
},
},
methods: {
tr(key, fallback = key) { return t(key, fallback) },
normalizeHexColor,
getColorDefault,
coerce(v) { return coerceValueByType(v, this.param.data_type) },
@@ -177,11 +179,11 @@ export const GalaxyToggleCard = {
<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">{{ displayParam.label }}
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
<span class="gx-row__label">{{ tr(displayParam.label, displayParam.label) }}
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">{{ tr("Advanced") }}</span>
</span>
<span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> {{ lockMessage }}</div>
<span v-if="displayParam.description" class="gx-row__desc">{{ tr(displayParam.description, displayParam.description) }}</span>
<div v-if="locked" class="gx-row__desc"><strong>{{ tr("Locked:") }}</strong> {{ tr(lockMessage, lockMessage) }}</div>
</div>
<label v-if="isSwitch" class="gx-switch">
@@ -202,15 +204,15 @@ export const GalaxyToggleCard = {
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<div v-if="displayParam.unit_type" class="gx-slider-meta">
<span>{{ sliderRangeDisplay }}</span>
<span>Step: {{ sliderStepDisplay }}</span>
<span>{{ tr("Step:") }} {{ sliderStepDisplay }}</span>
</div>
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">{{ tr("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>
<option v-if="optionsLoading" value="">{{ tr("Loading...") }}</option>
<option v-else-if="!selectOptions.length" value="">{{ tr("No options available") }}</option>
<option v-for="opt in selectOptions" :key="String(opt.value)" :value="String(opt.value)">{{ tr(opt.label, opt.label) }}</option>
</select>
<input v-else-if="isText" class="gx-field" :type="param.input_type || 'text'" :value="value ?? ''"
@@ -220,19 +222,19 @@ export const GalaxyToggleCard = {
<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>
<button class="gx-slider-reset" :disabled="locked || updating || !normalizeHexColor(value)" @click="resetColor">{{ tr("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") }}
{{ updating ? tr("Working...") : tr(param.action_label || "Run", param.action_label || "Run") }}
</button>
<button v-else-if="isGroup" class="gx-btn gx-btn--tonal" @click="$emit('manage', param.key)">Manage</button>
<button v-else-if="isGroup" class="gx-btn gx-btn--tonal" @click="$emit('manage', param.key)">{{ tr("Manage") }}</button>
</div>
<button v-if="manageable" type="button" class="gx-manage-btn" @click="$emit('manage', param.key)">
{{ manageOpen ? "Close" : "Manage" }}
{{ manageOpen ? tr("Close") : tr("Manage") }}
<i class="bi" :class="manageOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
@@ -0,0 +1,65 @@
import { api, showSnackbar } from "../api.js"
import { LANGUAGE_OPTIONS, languageState, normalizeLanguage, setLanguage, t } from "../i18n.js"
export const LanguageSelector = {
name: "LanguageSelector",
props: { deviceValue: { type: String, default: "" } },
data() {
return { languages: LANGUAGE_OPTIONS, selected: languageState.code, saving: false, error: "" }
},
computed: {
languageCode() { return languageState.code },
},
watch: {
languageCode(value) { this.selected = value },
deviceValue: {
immediate: true,
handler(value) {
if (!value) return
this.selected = setLanguage(value)
},
},
},
methods: {
tr(key, fallback = key) { return t(key, fallback) },
async change(event) {
const next = normalizeLanguage(event.target.value)
const previous = languageState.code
this.selected = next
this.error = ""
setLanguage(next)
this.saving = true
try {
// LanguageSetting is shared with the native UI, which stores the
// language catalog values as main_<locale>.
await api.updateParam({ key: "LanguageSetting", value: `main_${next}` })
showSnackbar(t("Language updated.", "Language updated."))
} catch (err) {
setLanguage(previous)
this.selected = previous
this.error = err?.message || t("Unable to save language.", "Unable to save language.")
showSnackbar(this.error, "error")
} finally {
this.saving = false
}
},
},
template: `
<div class="gx-card" style="margin-bottom:16px;">
<div class="gx-section__header">
<i class="bi bi-translate"></i>
<span class="gx-section__title">{{ tr("Language") }}</span>
</div>
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:10px; flex:1; min-width:220px;">
<span>{{ tr("Select language") }}</span>
<select class="gx-field" style="max-width:220px;" :value="selected" :disabled="saving" @change="change">
<option v-for="option in languages" :key="option.value" :value="option.value">{{ tr(option.label, option.label) }}</option>
</select>
</label>
<small class="gx-row__desc">{{ tr("Galaxy uses English when no language is selected.") }}</small>
</div>
<p v-if="error" class="gx-row__desc" style="color:var(--danger); margin:8px 0 0;">{{ error }}</p>
</div>
`,
}
@@ -0,0 +1,132 @@
import { reactive } from "vue"
const STORAGE_KEY = "galaxy-language"
export const LANGUAGE_OPTIONS = [
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "ko", label: "Korean" },
{ value: "zh-CHS", label: "Chinese" },
]
const SUPPORTED_CODES = new Set(LANGUAGE_OPTIONS.map((option) => option.value))
// Galaxy deliberately keeps English as the fallback. This lets new server-side
// labels ship safely before they have been added to every translation below.
const TRANSLATIONS = {
es: {
English: "Inglés", Spanish: "Español", French: "Francés", Korean: "Coreano", Chinese: "Chino",
Home: "Inicio", Toggles: "Interruptores", Tools: "Herramientas", Recordings: "Grabaciones",
Bluetooth: "Bluetooth", "Cameras & Monitoring": "Cámaras y monitoreo", Galaxy: "Galaxy",
"Logs & Diagnostics": "Registros y diagnósticos", "Model Manager": "Administrador de modelos",
"Navigation & Maps": "Navegación y mapas", "System Tools": "Herramientas del sistema",
"Model Laboratory": "Laboratorio de modelos", Plots: "Gráficas", "Testing Ground": "Área de pruebas",
"Theme Maker": "Creador de temas", "Tuning, Plots & Testing": "Ajustes, gráficas y pruebas",
"Vehicle Controls": "Controles del vehículo", Main: "Principal", Offline: "Sin conexión", Parked: "Estacionado",
Back: "Atrás", Menu: "Menú", "Galaxy home": "Inicio de Galaxy", "Search toggles...": "Buscar interruptores...",
"Search toggles": "Buscar interruptores", "Clear search": "Borrar búsqueda", "Dark mode": "Modo oscuro",
"Light mode": "Modo claro", "Switch to dark mode": "Cambiar a modo oscuro", "Switch to light mode": "Cambiar a modo claro",
Settings: "Configuración", Language: "Idioma", "Select language": "Seleccionar idioma", Advanced: "Avanzado",
"result(s)": "resultado(s)",
"Galaxy uses English when no language is selected.": "Galaxy usa inglés si no se selecciona un idioma.",
"Language updated.": "Idioma actualizado.", "Unable to save language.": "No se pudo guardar el idioma.",
"Loading configuration...": "Cargando configuración...", "No settings available.": "No hay ajustes disponibles.",
"No settings in this section.": "No hay ajustes en esta sección.", "Locked:": "Bloqueado:", "Step:": "Paso:",
"This setting can only be changed while parked.": "Este ajuste solo se puede cambiar mientras el vehículo está estacionado.",
Default: "Predeterminado", "Loading...": "Cargando...", "No options available": "No hay opciones disponibles",
"Working...": "Procesando...", Run: "Ejecutar", Manage: "Administrar", Close: "Cerrar", Stock: "Original",
},
fr: {
English: "Anglais", Spanish: "Espagnol", French: "Français", Korean: "Coréen", Chinese: "Chinois",
Home: "Accueil", Toggles: "Options", Tools: "Outils", Recordings: "Enregistrements",
Bluetooth: "Bluetooth", "Cameras & Monitoring": "Caméras et surveillance", Galaxy: "Galaxy",
"Logs & Diagnostics": "Journaux et diagnostics", "Model Manager": "Gestionnaire de modèles",
"Navigation & Maps": "Navigation et cartes", "System Tools": "Outils système",
"Model Laboratory": "Laboratoire de modèles", Plots: "Graphiques", "Testing Ground": "Zone de test",
"Theme Maker": "Créateur de thèmes", "Tuning, Plots & Testing": "Réglages, graphiques et tests",
"Vehicle Controls": "Commandes du véhicule", Main: "Principal", Offline: "Hors ligne", Parked: "Stationné",
Back: "Retour", Menu: "Menu", "Galaxy home": "Accueil Galaxy", "Search toggles...": "Rechercher des options...",
"Search toggles": "Rechercher des options", "Clear search": "Effacer la recherche", "Dark mode": "Mode sombre",
"Light mode": "Mode clair", "Switch to dark mode": "Passer au mode sombre", "Switch to light mode": "Passer au mode clair",
Settings: "Paramètres", Language: "Langue", "Select language": "Choisir la langue", Advanced: "Avancé",
"result(s)": "résultat(s)",
"Galaxy uses English when no language is selected.": "Galaxy utilise langlais si aucune langue nest sélectionnée.",
"Language updated.": "Langue mise à jour.", "Unable to save language.": "Impossible denregistrer la langue.",
"Loading configuration...": "Chargement de la configuration...", "No settings available.": "Aucun réglage disponible.",
"No settings in this section.": "Aucun réglage dans cette section.", "Locked:": "Verrouillé :", "Step:": "Pas :",
"This setting can only be changed while parked.": "Ce réglage ne peut être modifié que lorsque le véhicule est stationné.",
Default: "Par défaut", "Loading...": "Chargement...", "No options available": "Aucune option disponible",
"Working...": "En cours...", Run: "Exécuter", Manage: "Gérer", Close: "Fermer", Stock: "Origine",
},
ko: {
English: "영어", Spanish: "스페인어", French: "프랑스어", Korean: "한국어", Chinese: "중국어",
Home: "홈", Toggles: "토글", Tools: "도구", Recordings: "녹화",
Bluetooth: "블루투스", "Cameras & Monitoring": "카메라 및 모니터링", Galaxy: "Galaxy",
"Logs & Diagnostics": "로그 및 진단", "Model Manager": "모델 관리자",
"Navigation & Maps": "내비게이션 및 지도", "System Tools": "시스템 도구",
"Model Laboratory": "모델 연구소", Plots: "플롯", "Testing Ground": "테스트 공간",
"Theme Maker": "테마 만들기", "Tuning, Plots & Testing": "튜닝, 플롯 및 테스트",
"Vehicle Controls": "차량 제어", Main: "메인", Offline: "오프라인", Parked: "주차됨",
Back: "뒤로", Menu: "메뉴", "Galaxy home": "Galaxy 홈", "Search toggles...": "토글 검색...",
"Search toggles": "토글 검색", "Clear search": "검색 지우기", "Dark mode": "다크 모드",
"Light mode": "라이트 모드", "Switch to dark mode": "다크 모드로 전환", "Switch to light mode": "라이트 모드로 전환",
Settings: "설정", Language: "언어", "Select language": "언어 선택", Advanced: "고급",
"result(s)": "개 결과",
"Galaxy uses English when no language is selected.": "언어를 선택하지 않으면 Galaxy는 영어를 사용합니다.",
"Language updated.": "언어가 업데이트되었습니다.", "Unable to save language.": "언어를 저장할 수 없습니다.",
"Loading configuration...": "설정을 불러오는 중...", "No settings available.": "사용 가능한 설정이 없습니다.",
"No settings in this section.": "이 섹션에 설정이 없습니다.", "Locked:": "잠김:", "Step:": "단계:",
"This setting can only be changed while parked.": "이 설정은 주차 중에만 변경할 수 있습니다.",
Default: "기본값", "Loading...": "로드 중...", "No options available": "사용 가능한 옵션이 없습니다",
"Working...": "처리 중...", Run: "실행", Manage: "관리", Close: "닫기", Stock: "기본",
},
"zh-CHS": {
English: "英语", Spanish: "西班牙语", French: "法语", Korean: "韩语", Chinese: "中文",
Home: "主页", Toggles: "开关", Tools: "工具", Recordings: "录制内容",
Bluetooth: "蓝牙", "Cameras & Monitoring": "摄像头和监控", Galaxy: "Galaxy",
"Logs & Diagnostics": "日志和诊断", "Model Manager": "模型管理器",
"Navigation & Maps": "导航和地图", "System Tools": "系统工具",
"Model Laboratory": "模型实验室", Plots: "图表", "Testing Ground": "测试区",
"Theme Maker": "主题制作器", "Tuning, Plots & Testing": "调校、图表和测试",
"Vehicle Controls": "车辆控制", Main: "主菜单", Offline: "离线", Parked: "已停车",
Back: "返回", Menu: "菜单", "Galaxy home": "Galaxy 主页", "Search toggles...": "搜索开关...",
"Search toggles": "搜索开关", "Clear search": "清除搜索", "Dark mode": "深色模式",
"Light mode": "浅色模式", "Switch to dark mode": "切换到深色模式", "Switch to light mode": "切换到浅色模式",
Settings: "设置", Language: "语言", "Select language": "选择语言", Advanced: "高级",
"result(s)": "个结果",
"Galaxy uses English when no language is selected.": "未选择语言时,Galaxy 将使用英语。",
"Language updated.": "语言已更新。", "Unable to save language.": "无法保存语言。",
"Loading configuration...": "正在加载配置...", "No settings available.": "没有可用设置。",
"No settings in this section.": "此部分没有设置。", "Locked:": "已锁定:", "Step:": "步长:",
"This setting can only be changed while parked.": "此设置只能在车辆停放时更改。",
Default: "默认值", "Loading...": "加载中...", "No options available": "没有可用选项",
"Working...": "处理中...", Run: "运行", Manage: "管理", Close: "关闭", Stock: "原厂",
},
}
function storageValue() {
try { return window.localStorage.getItem(STORAGE_KEY) || "en" } catch (e) { return "en" }
}
export function normalizeLanguage(value) {
const code = String(value || "").trim().replace(/^main_/i, "")
return SUPPORTED_CODES.has(code) ? code : "en"
}
export const languageState = reactive({ code: normalizeLanguage(storageValue()) })
export function setLanguage(value) {
const code = normalizeLanguage(value)
languageState.code = code
try { window.localStorage.setItem(STORAGE_KEY, code) } catch (e) { /* storage can be unavailable in private webviews */ }
if (typeof document !== "undefined") document.documentElement.lang = code === "zh-CHS" ? "zh-CN" : code
return code
}
export function t(key, fallback = key) {
const source = String(key ?? "")
return TRANSLATIONS[languageState.code]?.[source] || fallback || source
}
setLanguage(languageState.code)
@@ -11,6 +11,8 @@ import { PersonalityProfiles } from "../components/PersonalityProfiles.js"
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
import { LanguageSelector } from "../components/LanguageSelector.js"
import { setLanguage, t } from "../i18n.js"
const LEGACY_PERSONALITY_KEYS = new Set([
"AccelerationProfile", "AggressiveFollow", "AggressiveFollowHigh", "CustomAccelProfile",
@@ -22,7 +24,7 @@ const LEGACY_PERSONALITY_KEYS = new Set([
export const Settings = {
name: "Settings",
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner, LongitudinalMode },
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner, LongitudinalMode, LanguageSelector },
data() {
return {
layout: [],
@@ -68,6 +70,7 @@ export const Settings = {
},
},
methods: {
tr(key, fallback = key) { return t(key, fallback) },
isModeParam(p) { return p.key === LONGITUDINAL_MODE_KEY || !!p.longitudinal_mode },
modeSection(s) { return this.layout.find(section => section.name === s.name && section.params.some(p => p.key === LONGITUDINAL_MODE_KEY)) },
ordinaryParams(s) { return s.params.filter(p => !this.isModeParam(p)) },
@@ -78,6 +81,7 @@ export const Settings = {
])
this.layout = longitudinalModeLayout(layout)
this.values = values || {}
setLanguage(this.values.LanguageSetting || "en")
this.defaults = defaults || {}
if (!this.activeSectionSlug && this.sections.length) {
const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
@@ -136,22 +140,24 @@ export const Settings = {
},
template: `
<div>
<h2 style="margin-top:0;">Toggles</h2>
<h2 style="margin-top:0;">{{ tr("Toggles") }}</h2>
<LanguageSelector :device-value="String(values.LanguageSetting || '')" />
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
<div v-if="loading" class="gx-loading">Loading configuration...</div>
<div v-if="loading" class="gx-loading">{{ tr("Loading configuration...") }}</div>
<template v-else-if="sections.length">
<div v-if="searchActive">
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-search"></i>
<span class="gx-section__title">{{ searchResults.reduce((n, s) => n + s.matches.length, 0) }} result(s)</span>
<span class="gx-section__title">{{ searchResults.reduce((n, s) => n + s.matches.length, 0) }} {{ tr("result(s)") }}</span>
</div>
</div>
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<GalaxySection :title="tr(section.name, section.name) + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<LongitudinalMode v-if="section.matches.some(isModeParam)" :section="modeSection(section)" :values="values" @change="onParamChange" />
<template v-for="p in section.matches" :key="p.key">
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="!!expanded[p.key]" @manage="toggleManage(p.key)" @change="onParamChange" />
@@ -167,24 +173,24 @@ export const Settings = {
<button v-for="s in sections" :key="s.slug" type="button"
class="gx-chip" :style="s.slug === activeSection.slug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
@click="selectSection(s.slug)">
{{ s.name }}
{{ tr(s.name, s.name) }}
</button>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ activeSection.name }}</span>
<span class="gx-section__title">{{ tr(activeSection.name, activeSection.name) }}</span>
</div>
<LongitudinalMode v-if="modeSection(activeSection)" :section="modeSection(activeSection)" :values="values" @change="onParamChange" />
<SettingTree :params="ordinaryParams(activeSection)" :parent-key="null" :values="values"
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
<div v-if="!activeSection.params.length" class="gx-empty">{{ tr("No settings in this section.") }}</div>
</div>
</div>
</template>
<div v-else class="gx-empty">No settings available.</div>
<div v-else class="gx-empty">{{ tr("No settings available.") }}</div>
</div>
`,
}
@@ -24,6 +24,7 @@ def test_ui_app_shell_files_exist():
"js/store.js",
"js/api.js",
"js/params.js",
"js/i18n.js",
"js/components/AppShell.js",
"js/components/GalaxyModal.js",
"js/components/GalaxySection.js",
@@ -35,6 +36,7 @@ def test_ui_app_shell_files_exist():
"js/components/WheelControls.js",
"js/components/BluetoothPanel.js",
"js/components/DevModeBanner.js",
"js/components/LanguageSelector.js",
"js/composables.js",
"js/views/Home.js",
"js/views/Settings.js",
@@ -73,6 +75,19 @@ def test_ui_uses_same_backend_endpoints():
assert '"/api/params/defaults"' in api
def test_ui_language_selector_uses_shared_device_language_setting():
i18n = _read("js/i18n.js")
selector = _read("js/components/LanguageSelector.js")
settings = _read("js/views/Settings.js")
for code in ["en", "es", "fr", "ko", "zh-CHS"]:
assert f'value: "{code}"' in i18n
assert "localStorage" in i18n
assert "LanguageSetting" in selector
assert "main_${next}" in selector
assert "<LanguageSelector" in settings
def test_ui_ports_developer_mode_gating():
params = _read("js/params.js")
assert "countAdvancedHiddenByDeveloperMode" in params