mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Repair CI Contracts
This commit is contained in:
@@ -157,12 +157,24 @@ def civic_engagement_owner(*, enabled=True, lkas_action=AOL_TOGGLE, main_action=
|
||||
cp.alternativeExperience = 32
|
||||
assert cp.flags == 2060 and cp.safetyConfigs[0].safetyParam == 10
|
||||
settings = AolSettings(enabled, 5., lkas_action, main_action, (0, 0, 0), (0, 0, 0))
|
||||
return cp, create_intent(cp, settings)
|
||||
intent = create_intent(cp, settings)
|
||||
intent.observe_full_engagement(state(), enabled=False, active=False, source_ns=1,
|
||||
observation_valid=True, now_ns=1, fault_active=False)
|
||||
return cp, intent
|
||||
|
||||
|
||||
def update_engagement(intent, cs, *, now_ns, standard_enabled=False, standard_active=False,
|
||||
fault_active=False, native_rejection_ns=0):
|
||||
intent.update(cs, now_ns=now_ns, standard_enabled=standard_enabled,
|
||||
fault_active=fault_active, native_rejection_ns=native_rejection_ns)
|
||||
intent.observe_full_engagement(cs, enabled=standard_enabled or standard_active, active=standard_active,
|
||||
source_ns=now_ns, observation_valid=True, now_ns=now_ns,
|
||||
fault_active=fault_active, native_rejection_ns=native_rejection_ns)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("main_action", (0, AOL_TOGGLE))
|
||||
@pytest.mark.parametrize("paused,temporary", ((False, False), (True, False), (False, True)))
|
||||
def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(main_action, paused, temporary):
|
||||
def test_active_engagement_arms_without_raw_cruise_resumes_lateral_and_preserves_long_pause(main_action, paused, temporary):
|
||||
from openpilot.starpilot.aol.runtime import decide_axes
|
||||
|
||||
_, intent = civic_engagement_owner(main_action=main_action)
|
||||
@@ -171,14 +183,14 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m
|
||||
cs.gasPressed = True
|
||||
assert not cs.cruiseState.enabled # Non-PCM host engagement can precede ACC_STATUS.
|
||||
intent.pause_lateral = intent.pause_longitudinal = paused
|
||||
intent.update(cs.as_reader(), now_ns=10, fault_active=False, standard_enabled=True)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False, standard_enabled=True)
|
||||
assert not intent.allowed_latch # Enabled/preEnabled alone is not the active edge.
|
||||
intent.update(cs.as_reader(), now_ns=20, fault_active=False, standard_enabled=True, standard_active=True)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=20, fault_active=False, standard_enabled=True, standard_active=True)
|
||||
assert intent.allowed_latch
|
||||
assert intent.pause_lateral is paused and intent.pause_longitudinal is paused
|
||||
assert not intent.pause_lateral and intent.pause_longitudinal is paused
|
||||
cs.gasPressed = False
|
||||
cs.brakePressed = True
|
||||
intent.update(cs.as_reader(), now_ns=30, fault_active=False, standard_active=False)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=30, fault_active=False, standard_active=False)
|
||||
assert intent.allowed_latch # Braking drops normal cruise, retaining AOL intent.
|
||||
allowed, pause_lat, pause_long = intent.output(cs.as_reader())
|
||||
snapshot = SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long)
|
||||
@@ -188,7 +200,7 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m
|
||||
car_state=cs.as_reader(), initialized=True, model_ready=True,
|
||||
no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.)
|
||||
decision = decide_axes(native=native, **kwargs)
|
||||
assert decision.lateral_active == (not paused and not temporary)
|
||||
assert decision.lateral_active == (not temporary)
|
||||
assert not decision.longitudinal_active
|
||||
assert not decide_axes(native=None, **kwargs).lateral_active
|
||||
cs.vEgo = 4.9
|
||||
@@ -198,12 +210,12 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m
|
||||
assert not decide_axes(native=native, **kwargs).lateral_active
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blocked", ("disabled", "mapping", "main_off", "can_invalid", "timeout",
|
||||
@pytest.mark.parametrize("blocked", ("disabled", "main_off", "can_invalid", "timeout",
|
||||
"fatal", "permanent", "acc_fault", "native_rejection", "park", "unknown", "reverse", "neutral"))
|
||||
def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocked):
|
||||
_, intent = civic_engagement_owner(enabled=blocked != "disabled", lkas_action=0 if blocked == "mapping" else AOL_TOGGLE)
|
||||
_, intent = civic_engagement_owner(enabled=blocked != "disabled")
|
||||
cs = structs.CarState(**state().to_dict())
|
||||
intent.update(cs.as_reader(), now_ns=10, fault_active=False)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False)
|
||||
if blocked == "main_off":
|
||||
cs.cruiseState.available = False
|
||||
for case, field in (("can_invalid", "canValid"), ("timeout", "canTimeout"),
|
||||
@@ -212,14 +224,14 @@ def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocke
|
||||
setattr(cs, field, case != "can_invalid")
|
||||
if blocked in ("park", "unknown", "reverse", "neutral"):
|
||||
cs.gearShifter = blocked
|
||||
intent.update(cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True,
|
||||
update_engagement(intent, cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True,
|
||||
fault_active=blocked == "fatal", native_rejection_ns=20 if blocked == "native_rejection" else 0)
|
||||
assert not intent.allowed_latch
|
||||
intent.settings = replace(intent.settings, enabled=True, lkas_action=AOL_TOGGLE)
|
||||
intent.update(state(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch
|
||||
intent.update(state(), now_ns=40, fault_active=False)
|
||||
intent.update(state(), now_ns=50, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=40, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=50, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert intent.allowed_latch
|
||||
|
||||
|
||||
@@ -227,42 +239,65 @@ def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocke
|
||||
def test_manual_gesture_takes_priority_over_coincident_active_edge(button):
|
||||
_, intent = civic_engagement_owner(main_action=AOL_TOGGLE)
|
||||
cs = structs.CarState(**state().to_dict())
|
||||
intent.update(cs.as_reader(), now_ns=10, fault_active=False)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False)
|
||||
cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=True)]
|
||||
intent.update(cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch
|
||||
cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=False)]
|
||||
intent.update(cs.as_reader(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, cs.as_reader(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch
|
||||
|
||||
|
||||
def test_manual_off_and_native_withdrawal_do_not_rearm_while_active_stays_high():
|
||||
_, intent = civic_engagement_owner()
|
||||
intent.update(state(), now_ns=10, fault_active=False)
|
||||
intent.update(state(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=10, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert intent.allowed_latch
|
||||
intent.update(state(True), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(True), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch and intent.pause_lateral
|
||||
intent.update(state(False), now_ns=40, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(False), now_ns=40, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch and intent.pause_lateral
|
||||
intent.update(state(), now_ns=50, fault_active=False)
|
||||
intent.update(state(), now_ns=60, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert intent.allowed_latch and intent.pause_lateral # A new SET edge keeps deliberate output pause.
|
||||
intent.update(state(), now_ns=70, standard_enabled=True, standard_active=True, native_rejection_ns=70, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=50, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=60, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert intent.allowed_latch and not intent.pause_lateral # A new full SET resumes lateral output.
|
||||
update_engagement(intent, state(), now_ns=70, standard_enabled=True, standard_active=True, native_rejection_ns=70, fault_active=False)
|
||||
assert not intent.allowed_latch
|
||||
intent.update(state(), now_ns=80, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(), now_ns=80, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert not intent.allowed_latch
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gear", ("drive", "sport", "low", "brake"))
|
||||
def test_supported_driving_gears_keep_active_engagement_intent(gear):
|
||||
_, intent = civic_engagement_owner()
|
||||
intent.update(state(gear=gear), now_ns=10, fault_active=False)
|
||||
intent.update(state(gear=gear), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
update_engagement(intent, state(gear=gear), now_ns=10, fault_active=False)
|
||||
update_engagement(intent, state(gear=gear), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False)
|
||||
assert intent.allowed_latch
|
||||
|
||||
|
||||
def test_actual_card_active_observation_requires_fresh_applied_command(monkeypatch):
|
||||
@pytest.mark.parametrize("lkas_action", (0, AOL_TOGGLE))
|
||||
def test_full_engagement_does_not_require_a_configured_lkas_toggle(lkas_action):
|
||||
_, intent = civic_engagement_owner(lkas_action=lkas_action)
|
||||
update_engagement(intent, state(), now_ns=10, standard_enabled=True, standard_active=False)
|
||||
assert not intent.allowed_latch
|
||||
update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True)
|
||||
assert intent.allowed_latch
|
||||
update_engagement(intent, state(), now_ns=30, standard_enabled=False, standard_active=False)
|
||||
assert intent.allowed_latch
|
||||
|
||||
|
||||
def test_starting_during_active_engagement_requires_a_fresh_disabled_epoch():
|
||||
cp, seeded = civic_engagement_owner()
|
||||
intent = create_intent(cp, seeded.settings)
|
||||
update_engagement(intent, state(), now_ns=10, standard_enabled=True, standard_active=True)
|
||||
update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True)
|
||||
assert not intent.allowed_latch
|
||||
update_engagement(intent, state(), now_ns=30)
|
||||
update_engagement(intent, state(), now_ns=40, standard_enabled=True, standard_active=True)
|
||||
assert intent.allowed_latch
|
||||
|
||||
|
||||
def test_actual_card_active_observation_requires_fresh_selfdrive_state(monkeypatch):
|
||||
from openpilot.cereal import log
|
||||
from openpilot.selfdrive.car import card as card_module
|
||||
from openpilot.selfdrive.car.card import Car
|
||||
from openpilot.starpilot.aol.intent import AolCardIntent
|
||||
@@ -279,11 +314,13 @@ def test_actual_card_active_observation_requires_fresh_applied_command(monkeypat
|
||||
cs = state()
|
||||
now = 1_000_000_000
|
||||
control = structs.CarControl(enabled=False, latActive=False, longActive=False)
|
||||
samples = Samples(carControl=control.as_reader(), onroadEvents=[])
|
||||
samples.valid = {"carControl": True, "onroadEvents": True}
|
||||
samples.alive = {"carControl": True}
|
||||
engagement = log.SelfdriveState.new_message(enabled=False, active=False)
|
||||
samples = Samples(carControl=control.as_reader(), onroadEvents=[], selfdriveState=engagement.as_reader())
|
||||
samples.valid = {"carControl": True, "onroadEvents": True, "selfdriveState": True}
|
||||
samples.alive = {"carControl": True, "selfdriveState": True}
|
||||
samples.seen = {"selfdriveState": True}
|
||||
samples.updated = {"onroadEvents": True}
|
||||
samples.logMonoTime = {"carControl": now, "onroadEvents": now}
|
||||
samples.logMonoTime = {"carControl": now, "onroadEvents": now, "selfdriveState": now}
|
||||
host = Car.__new__(Car)
|
||||
host.CP, host.aol_card_intent = cp, intent
|
||||
host.CI = SimpleNamespace(update=lambda _: cs, CS=SimpleNamespace())
|
||||
@@ -311,41 +348,45 @@ def test_actual_card_active_observation_requires_fresh_applied_command(monkeypat
|
||||
"fixture-panda", host.slc_producer_session)
|
||||
monkeypatch.setattr(card_module, "current_native", lambda *args, **kwargs: native)
|
||||
observed = []
|
||||
update = intent.update
|
||||
observe = intent.observe_full_engagement
|
||||
|
||||
def at_boundary(state, **kwargs):
|
||||
observed.append(kwargs)
|
||||
update(state, **kwargs)
|
||||
observe(state, **kwargs)
|
||||
raise BoundaryReached
|
||||
|
||||
monkeypatch.setattr(intent, "update", at_boundary)
|
||||
monkeypatch.setattr(intent, "observe_full_engagement", at_boundary)
|
||||
|
||||
def tick(*, previous=False, enabled=True, lat=False, long=False, valid=True, alive=True, age=0):
|
||||
def tick(*, enabled=True, active=False, valid=True, alive=True, seen=True, age=0, control_active=True):
|
||||
nonlocal now
|
||||
now += 10_000_000
|
||||
control.enabled, control.latActive, control.longActive = enabled, lat, long
|
||||
previous_enabled = bool(control.enabled)
|
||||
control.enabled, control.latActive, control.longActive = enabled, control_active, False
|
||||
engagement.enabled, engagement.active = enabled, active
|
||||
samples["carControl"] = control.as_reader()
|
||||
samples.valid["carControl"], samples.alive["carControl"] = valid, alive
|
||||
samples.logMonoTime.update(carControl=now - age, onroadEvents=now)
|
||||
host.CC_prev = structs.CarControl(enabled=previous).as_reader()
|
||||
samples["selfdriveState"] = engagement.as_reader()
|
||||
samples.valid["selfdriveState"], samples.alive["selfdriveState"] = valid, alive
|
||||
samples.seen["selfdriveState"] = seen
|
||||
samples.logMonoTime.update(carControl=now, onroadEvents=now, selfdriveState=now - age)
|
||||
host.CC_prev = structs.CarControl(enabled=previous_enabled).as_reader()
|
||||
with pytest.raises(BoundaryReached):
|
||||
host.state_update()
|
||||
return observed[-1]
|
||||
|
||||
assert not tick(previous=True)["standard_active"] # PreEnabled: enabled with neither active axis.
|
||||
assert not tick(enabled=False)["active"]
|
||||
assert not tick()["active"] # PreEnabled: fresh actuator activity cannot invent ordinary active state.
|
||||
assert not intent.allowed_latch
|
||||
for kwargs in ({"previous": False}, {"previous": True, "valid": False},
|
||||
{"previous": True, "alive": False}, {"previous": True, "age": 200_000_000}):
|
||||
assert not tick(lat=True, **kwargs)["standard_active"]
|
||||
for kwargs in ({"valid": False}, {"alive": False}, {"seen": False}, {"age": 200_000_000}, {"age": -1}):
|
||||
assert not tick(active=True, **kwargs)["observation_valid"]
|
||||
assert not intent.allowed_latch
|
||||
assert tick(previous=True, lat=True)["standard_active"]
|
||||
assert tick(active=True)["observation_valid"]
|
||||
assert intent.allowed_latch and not cs.cruiseState.enabled
|
||||
intent.allowed_latch = False
|
||||
tick(previous=True, lat=True)
|
||||
assert not intent.allowed_latch # Repeating the same active level is not a new SET.
|
||||
tick(active=True)
|
||||
assert not intent.allowed_latch # Repeating the same enabled epoch is not a new SET.
|
||||
tick(enabled=False)
|
||||
assert tick(previous=True, long=True)["standard_active"]
|
||||
assert intent.allowed_latch # Actual normal long activity also proves active engagement.
|
||||
assert tick(active=True, control_active=False)["active"]
|
||||
assert intent.allowed_latch # Actual ordinary state proves engagement independently of actuator samples.
|
||||
|
||||
# The actual Card caller consumes the acknowledged native permission. Its
|
||||
# immediate rejection boundary remains strict; no host grace is extended.
|
||||
|
||||
@@ -26,6 +26,9 @@ unrecorded_route_gaps = {
|
||||
GM.CHEVROLET_MALIBU_HYBRID_CC: "gm/tests/test_ordinary_cc.py",
|
||||
TOYOTA.TOYOTA_PRIUS_RETROFIT: "toyota/tests/test_retrofit.py",
|
||||
TESLA.TESLA_MODEL_S_HW1: "tesla/tests/test_hw1.py",
|
||||
VOLVO.VOLVO_XC40_RECHARGE: "volvo/tests/test_cma.py",
|
||||
VOLVO.VOLVO_S60_RECHARGE: "volvo/tests/test_cma.py",
|
||||
VOLVO.POLESTAR_2: "volvo/tests/test_cma.py",
|
||||
}
|
||||
|
||||
# FIXME: add routes for these cars
|
||||
|
||||
@@ -6,6 +6,7 @@ from opendbc.car.tests.routes import non_tested_cars, routes, unrecorded_route_g
|
||||
from opendbc.car.gm.values import CAR as GM
|
||||
from opendbc.car.tesla.values import CAR as TESLA
|
||||
from opendbc.car.toyota.values import CAR as TOYOTA
|
||||
from opendbc.car.volvo.values import CAR as VOLVO
|
||||
|
||||
|
||||
class TestRoutes(unittest.TestCase):
|
||||
@@ -19,6 +20,7 @@ class TestRoutes(unittest.TestCase):
|
||||
def test_explicit_unrecorded_configuration_gaps(self):
|
||||
self.assertEqual(set(unrecorded_route_gaps), {
|
||||
GM.CHEVROLET_VOLT_CC, GM.CHEVROLET_MALIBU_HYBRID_CC, TOYOTA.TOYOTA_PRIUS_RETROFIT, TESLA.TESLA_MODEL_S_HW1,
|
||||
VOLVO.VOLVO_XC40_RECHARGE, VOLVO.VOLVO_S60_RECHARGE, VOLVO.POLESTAR_2,
|
||||
})
|
||||
self.assertFalse(set(unrecorded_route_gaps) & {route.car_model for route in routes})
|
||||
self.assertTrue(set(unrecorded_route_gaps) <= set(non_tested_cars))
|
||||
|
||||
@@ -11,14 +11,14 @@ INVENTORY = (
|
||||
)
|
||||
|
||||
|
||||
def run_context(identity, alternate, aol):
|
||||
def run_context(identity, alternate, aol, *, cancel_assigned=False):
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import Bus, gen_empty_fingerprint, structs
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.tests.test_torque_ev_startup import owner
|
||||
from opendbc.car.hyundai.values import DBC
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from openpilot.starpilot.aol.intent import AolSettings, AOL_TOGGLE
|
||||
from openpilot.starpilot.aol.intent import AolSettings, AOL_TOGGLE, PAUSE_LATERAL
|
||||
from openpilot.starpilot.car.hyundai.aol import create_intent, policy_for
|
||||
from openpilot.starpilot.vehicle_startup import VehicleStartupOwner
|
||||
from openpilot.selfdrive.car.car_events import CarEvents
|
||||
@@ -47,7 +47,8 @@ def run_context(identity, alternate, aol):
|
||||
assert int(cp.safetyConfigs[0].safetyParam) == ((0x95 if alternate else 0x15) | (0x800 if aol else 0))
|
||||
assert emitted and all(address == 0x730 and bus == 1 for address, _, bus in emitted)
|
||||
packer = CANPacker(DBC[identity][Bus.pt])
|
||||
intent = create_intent(cp, AolSettings(aol, 0., AOL_TOGGLE, AOL_TOGGLE, (0, 0, 0), (0, 0, 0)))
|
||||
cancel_actions = (PAUSE_LATERAL, 0, 0) if cancel_assigned else (0, 0, 0)
|
||||
intent = create_intent(cp, AolSettings(aol, 0., AOL_TOGGLE, AOL_TOGGLE, (0, 0, 0), cancel_actions))
|
||||
safety = libsafety_py.libsafety
|
||||
safety.init_tests()
|
||||
safety.set_alternative_experience(int(cp.alternativeExperience))
|
||||
@@ -62,10 +63,11 @@ def run_context(identity, alternate, aol):
|
||||
previous_control = structs.CarControl()
|
||||
active_long = False
|
||||
independent_lateral = False
|
||||
pre_cancel_edge_ns = 0
|
||||
try:
|
||||
for tick in range(340):
|
||||
now = epoch + tick * 10_000_000
|
||||
lda = tick in (40, 41, 150, 151, 300, 301)
|
||||
lda = tick in (40, 41, 150, 151, 300, 301) or (aol and not cancel_assigned and tick in (160, 161))
|
||||
set_pressed = tick in (80, 81, 180, 181, 212, 213, 304, 305)
|
||||
cancel = tick in (120, 121)
|
||||
gas = 200 <= tick < 210
|
||||
@@ -151,8 +153,24 @@ def run_context(identity, alternate, aol):
|
||||
independent_lateral |= bool(cc.latActive and not cc.longActive)
|
||||
if 202 <= tick < 210 or 232 <= tick < 240:
|
||||
assert not cc.longActive, (tick, 'physical pedal must withdraw LONG')
|
||||
if aol and tick == 118:
|
||||
pre_cancel_edge_ns = intent._last_latch_edge_ns
|
||||
assert cc.latActive and cc.longActive and pre_cancel_edge_ns > 0
|
||||
if aol and 124 <= tick < 148:
|
||||
assert not cc.latActive, (tick, 'cancel must revoke lateral')
|
||||
assert not cc.longActive, (tick, 'cancel must revoke longitudinal')
|
||||
if cancel_assigned:
|
||||
assert not cc.latActive, (tick, 'cancel must revoke lateral')
|
||||
assert not intent.allowed_latch and mask == 0
|
||||
else:
|
||||
# Unassigned CANCEL retains only the already claimed lateral token.
|
||||
assert cc.latActive and intent.allowed_latch and mask == 1
|
||||
assert intent._last_latch_edge_ns == pre_cancel_edge_ns, (tick, 'cancel cannot renew intent')
|
||||
if aol and not cancel_assigned and 154 <= tick < 160:
|
||||
assert not cc.latActive and intent.pause_lateral and not intent.allowed_latch
|
||||
assert mask == 0, (tick, 'physical LDA off must withdraw native lateral')
|
||||
if aol and not cancel_assigned and 164 <= tick < 178:
|
||||
assert cc.latActive and not cc.longActive and mask == 1
|
||||
assert intent._last_latch_edge_ns > pre_cancel_edge_ns, (tick, 'fresh LDA must rearm')
|
||||
assert active_long, 'No physical SET LONG epoch'
|
||||
assert independent_lateral == aol, 'Independent physical LDA epoch mismatch'
|
||||
|
||||
@@ -190,8 +208,9 @@ class TestPreparedTorqueEvNative(unittest.TestCase):
|
||||
for identity in (CAR.HYUNDAI_IONIQ_5, CAR.HYUNDAI_KONA_EV_2ND_GEN):
|
||||
for alternate in (False, True):
|
||||
for aol in (False, True):
|
||||
with self.subTest(identity=identity, alternate=alternate, aol=aol):
|
||||
run_context(identity, alternate, aol)
|
||||
for cancel_assigned in ((False, True) if aol else (False,)):
|
||||
with self.subTest(identity=identity, alternate=alternate, aol=aol, cancel_assigned=cancel_assigned):
|
||||
run_context(identity, alternate, aol, cancel_assigned=cancel_assigned)
|
||||
|
||||
def test_release_raw_long_preserves_stock_neutral_contract(self):
|
||||
from opendbc.can import CANPacker
|
||||
|
||||
@@ -7,6 +7,9 @@ if GetOption('extras') or any(target.startswith('openpilot/selfdrive/pandad/test
|
||||
env.Program('tests/test_panda_usb', ['tests/test_panda_usb.cc', usb_test], LIBS=[common, 'pthread'])
|
||||
env.Program('tests/test_aol_protocol', ['tests/test_aol_protocol.cc'])
|
||||
env.Program('tests/test_aol_wire', ['tests/test_aol_wire.cc'], LIBS=[messaging, 'capnp', 'kj'])
|
||||
# Compose producer freshness, session negotiation and native ACK wire contracts.
|
||||
env.Program('tests/test_producer_timing', ['tests/test_producer_timing.cc'], LIBS=[messaging, 'capnp', 'kj'])
|
||||
env.Program('tests/test_producer_sink', ['tests/test_producer_sink.cc'], LIBS=[messaging, 'capnp', 'kj'])
|
||||
|
||||
if arch != "Darwin":
|
||||
libs = [common, messaging, 'pthread', 'usb-1.0']
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Compose native status parsing/negotiation with the actual wire encoder.
|
||||
// This is a header contract test, not a claim of pandad thread/IPC coverage.
|
||||
#include "common/tests/native_test.h"
|
||||
#include "selfdrive/pandad/aol_protocol.h"
|
||||
#include "selfdrive/pandad/aol_wire.h"
|
||||
#include "openpilot/starpilot/car/hyundai/aol_policy.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint64_t NOW = 1000000000ULL;
|
||||
const AolProfileRegistry REGISTRY{&HYUNDAI_AOL_PROFILE, 1U};
|
||||
|
||||
aol_safety_health_t status(uint8_t request = 0U, uint8_t permission = 0U) {
|
||||
return {AOL_SAFETY_PROTOCOL_MAGIC, AOL_SAFETY_PROTOCOL_VERSION, request, permission,
|
||||
HYUNDAI_AOL_PROFILE.mode, 0x815U, 1U};
|
||||
}
|
||||
|
||||
std::optional<aol_safety_health_t> parsed(const aol_safety_health_t &native) {
|
||||
unsigned char bytes[sizeof(native)];
|
||||
std::memcpy(bytes, &native, sizeof(native));
|
||||
return parse_aol_status(bytes, sizeof(bytes));
|
||||
}
|
||||
|
||||
void check_wire(const AolOutcome &outcome, const std::string &session,
|
||||
uint8_t request, uint8_t permission) {
|
||||
CHECK(outcome.compatible && outcome.status);
|
||||
const auto &native = *outcome.status;
|
||||
// Same outcome-to-fields projection as pandad.cc. Requested/allowed bits
|
||||
// must originate in the observed ACK, never the desired axis/write plan.
|
||||
AolSafetyWireFields fields{
|
||||
AOL_SAFETY_PROTOCOL_VERSION, outcome.compatible, NOW, NOW + 200000000ULL,
|
||||
native.safety_mode, native.safety_param,
|
||||
(native.permission_mask & 1U) != 0U, (native.permission_mask & 2U) != 0U,
|
||||
(native.request_mask & 1U) != 0U, (native.request_mask & 2U) != 0U,
|
||||
"owner", outcome.session,
|
||||
{NOW + 9000000000ULL, {{0U, "owner", native.safety_mode, native.safety_param, 32U, false, false, false, 0U}}}
|
||||
};
|
||||
auto encoded = encode_aol_safety_wire(fields);
|
||||
CHECK(!encoded.empty() && encoded.size() <= 512U && encoded.size() % sizeof(capnp::word) == 0U);
|
||||
auto aligned = kj::heapArray<capnp::word>(encoded.size() / sizeof(capnp::word));
|
||||
std::memcpy(aligned.begin(), encoded.data(), encoded.size());
|
||||
capnp::FlatArrayMessageReader reader(aligned.asPtr());
|
||||
auto wire = reader.getRoot<cereal::AolAxisState::SafetyWire>();
|
||||
CHECK(wire.getCompatible() && wire.getProtocolVersion() == AOL_SAFETY_PROTOCOL_VERSION);
|
||||
CHECK(std::string(wire.getAxisSessionId().cStr()) == session);
|
||||
CHECK(wire.getRequestedLateral() == ((request & 1U) != 0U));
|
||||
CHECK(wire.getRequestedLongitudinal() == ((request & 2U) != 0U));
|
||||
CHECK(wire.getLateralAllowed() == ((permission & 1U) != 0U));
|
||||
CHECK(wire.getLongitudinalAllowed() == ((permission & 2U) != 0U));
|
||||
CHECK(wire.getObservedMonoTime() == NOW && wire.getValidUntilMonoTime() == NOW + 200000000ULL);
|
||||
CHECK(wire.getSourcePandaStatesMonoTime() == NOW + 9000000000ULL);
|
||||
CHECK(wire.getPandaInventory().size() == 1U);
|
||||
CHECK(wire.getPandaInventory()[0].getHardwareSerial() == "owner");
|
||||
}
|
||||
|
||||
void test_native_ack_to_wire() {
|
||||
AolAxisNegotiator negotiator(REGISTRY);
|
||||
AolAxisInput axis{true, true, "axis-old", NOW, NOW + 30000000ULL, NOW, true, true};
|
||||
auto plan = negotiator.prepare(parsed(status(3U, 3U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.capable && plan.request_mask == 0U);
|
||||
auto outcome = negotiator.complete(plan, true, parsed(status()));
|
||||
check_wire(outcome, "axis-old", 0U, 0U);
|
||||
|
||||
plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 3U);
|
||||
// Native may acknowledge LAT before LONG. Serialize that exact observation.
|
||||
outcome = negotiator.complete(plan, true, parsed(status(1U, 1U)));
|
||||
check_wire(outcome, "axis-old", 1U, 1U);
|
||||
plan = negotiator.prepare(parsed(status(1U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
outcome = negotiator.complete(plan, true, parsed(status(3U, 1U)));
|
||||
check_wire(outcome, "axis-old", 3U, 1U); // request is not permission
|
||||
|
||||
axis.session = "axis-new";
|
||||
plan = negotiator.prepare(parsed(status(3U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 0U);
|
||||
outcome = negotiator.complete(plan, true, parsed(status(3U, 1U)));
|
||||
check_wire(outcome, "", 3U, 1U); // delayed old ACK cannot bind the new session
|
||||
plan = negotiator.prepare(parsed(status(3U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 0U);
|
||||
outcome = negotiator.complete(plan, true, parsed(status()));
|
||||
check_wire(outcome, "axis-new", 0U, 0U);
|
||||
plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 3U);
|
||||
outcome = negotiator.complete(plan, true, parsed(status(3U, 3U)));
|
||||
check_wire(outcome, "axis-new", 3U, 3U);
|
||||
|
||||
// Every failed completion requires a new zero ACK before another request.
|
||||
for (unsigned failure = 0; failure < 4U; ++failure) {
|
||||
plan = negotiator.prepare(parsed(status(3U, 3U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
auto after = status(3U, 3U);
|
||||
if (failure == 1U) after.magic = 0U;
|
||||
if (failure == 2U) after.safety_param = 0U;
|
||||
auto ack = failure == 3U ? std::optional<aol_safety_health_t>{} : parsed(after);
|
||||
outcome = negotiator.complete(plan, failure != 0U, ack);
|
||||
CHECK(!outcome.compatible && !outcome.status && outcome.session.empty());
|
||||
plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 0U);
|
||||
outcome = negotiator.complete(plan, true, parsed(status()));
|
||||
check_wire(outcome, "axis-new", 0U, 0U);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() { return run_native_test(test_native_ack_to_wire); }
|
||||
@@ -0,0 +1,89 @@
|
||||
// Compose Card's real wire freshness predicate with native command negotiation.
|
||||
// No scheduler, clock, transport or production publisher is replaced here.
|
||||
#include "common/tests/native_test.h"
|
||||
#include "selfdrive/pandad/aol_protocol.h"
|
||||
#include "selfdrive/pandad/aol_wire.h"
|
||||
#include "openpilot/starpilot/car/hyundai/aol_policy.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint64_t NOW = 1000000000ULL;
|
||||
constexpr uint64_t CARD = NOW - 10000000ULL;
|
||||
constexpr uint64_t SOURCE = CARD - 40000000ULL;
|
||||
const AolProfileRegistry REGISTRY{&HYUNDAI_AOL_PROFILE, 1U};
|
||||
|
||||
const aol_safety_health_t STATUS{AOL_SAFETY_PROTOCOL_MAGIC, AOL_SAFETY_PROTOCOL_VERSION,
|
||||
0U, 0U, HYUNDAI_AOL_PROFILE.mode, 0x815U, 1U};
|
||||
|
||||
void test_card_and_axis_leases() {
|
||||
// A current Card arm can outlive an axis's short command lease, only in
|
||||
// the axis session that has already completed its own native zero ACK.
|
||||
for (unsigned mutation = 0; mutation < 10U; ++mutation) {
|
||||
AolAxisNegotiator negotiator(REGISTRY);
|
||||
AolAxisInput axis{true, true, "axis-1", NOW - 40000000ULL, NOW - 1U,
|
||||
NOW - 40000000ULL, true, true};
|
||||
auto initial = axis;
|
||||
initial.observed_ns = initial.message_ns = NOW;
|
||||
initial.valid_until_ns = NOW + 30000000ULL;
|
||||
auto zero = negotiator.prepare(STATUS, initial, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(zero.capable && zero.request_mask == 0U);
|
||||
CHECK(negotiator.complete(zero, true, STATUS).session == "axis-1");
|
||||
|
||||
capnp::MallocMessageBuilder builder;
|
||||
auto intent = builder.initRoot<cereal::AolAxisState::IntentWire>();
|
||||
intent.setKind(2U);
|
||||
intent.setVersion(1U);
|
||||
intent.setProducerSessionId("card-1");
|
||||
intent.setSequence(1U);
|
||||
intent.setCarStateLogMonoTime(CARD);
|
||||
intent.setObservedMonoTime(CARD);
|
||||
intent.setValidUntilMonoTime(NOW + 20000000ULL);
|
||||
intent.setSettingsQualified(true);
|
||||
intent.setLateralArmed(true);
|
||||
uint64_t stamp = CARD;
|
||||
uint64_t source = SOURCE;
|
||||
std::string producer = "card-1";
|
||||
if (mutation == 1U) producer = "replacement-card";
|
||||
if (mutation == 2U) stamp = NOW - 30000001ULL;
|
||||
if (mutation == 3U) intent.setSequence(0U);
|
||||
if (mutation == 4U) intent.setSettingsQualified(false);
|
||||
if (mutation == 5U) intent.setLateralArmed(false);
|
||||
if (mutation == 6U) intent.setObservedMonoTime(CARD - 1U);
|
||||
if (mutation == 7U) source = CARD - 200000001ULL;
|
||||
if (mutation == 8U) axis.session = "replacement-axis";
|
||||
if (mutation == 9U) axis.alive_valid = false;
|
||||
auto flat = capnp::messageToFlatArray(builder);
|
||||
axis.retain_lateral_arm = aol_armed_intent(flat.asBytes(), stamp, source, NOW, false, producer);
|
||||
CHECK(axis.retain_lateral_arm == (mutation == 0U || mutation >= 8U));
|
||||
const auto expired = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(expired.capable && expired.request_mask == 0U);
|
||||
CHECK(expired.retain_lateral_arm == (mutation == 0U));
|
||||
// No intent flag or source/session mutation can resurrect either command.
|
||||
CHECK((expired.request_mask & 3U) == 0U);
|
||||
}
|
||||
|
||||
// Boundary composition: Card and axis age limits are independent. A Card
|
||||
// at exactly 30 ms retains an expired axis arm at exactly 200 ms; moving
|
||||
// either clock one nanosecond past its own boundary removes retention.
|
||||
for (unsigned boundary = 0; boundary < 3U; ++boundary) {
|
||||
AolAxisNegotiator negotiator(REGISTRY);
|
||||
AolAxisInput axis{true, true, "boundary", NOW, NOW + 1U, NOW, true, true};
|
||||
auto zero = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(negotiator.complete(zero, true, STATUS).session == "boundary");
|
||||
const uint64_t card_ns = NOW - 30000000ULL - (boundary == 1U ? 1U : 0U);
|
||||
axis.observed_ns = axis.message_ns = NOW - AOL_AXIS_MESSAGE_MAX_AGE_NS - (boundary == 2U ? 1U : 0U);
|
||||
axis.valid_until_ns = axis.observed_ns + 30000000ULL;
|
||||
capnp::MallocMessageBuilder builder;
|
||||
auto intent = builder.initRoot<cereal::AolAxisState::IntentWire>();
|
||||
intent.setKind(2U); intent.setVersion(1U); intent.setSequence(1U);
|
||||
intent.setProducerSessionId("card-boundary");
|
||||
intent.setCarStateLogMonoTime(card_ns); intent.setObservedMonoTime(card_ns);
|
||||
intent.setValidUntilMonoTime(NOW); intent.setSettingsQualified(true); intent.setLateralArmed(true);
|
||||
auto flat = capnp::messageToFlatArray(builder);
|
||||
axis.retain_lateral_arm = aol_armed_intent(flat.asBytes(), card_ns, axis.observed_ns, NOW, false, "card-boundary");
|
||||
auto plan = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode);
|
||||
CHECK(plan.request_mask == 0U && plan.retain_lateral_arm == (boundary == 0U));
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() { return run_native_test(test_card_and_axis_leases); }
|
||||
@@ -117,7 +117,7 @@ def canonical_names(names):
|
||||
_RAW_BY_NAME = MappingProxyType({name: (value, limits) for name, value, limits in RAW_DOMAINS})
|
||||
|
||||
|
||||
def _coordinate_bounds_static(name):
|
||||
def _coordinate_bounds_static(name) -> tuple[float, float]:
|
||||
if name not in NAMES:
|
||||
raise ValueError('unknown canonical coordinate')
|
||||
original, (raw_low, raw_high) = _RAW_BY_NAME[name]
|
||||
@@ -134,7 +134,7 @@ def _coordinate_bounds_static(name):
|
||||
_COORDINATE_BOUNDS = MappingProxyType({name: _coordinate_bounds_static(name) for name in NAMES})
|
||||
|
||||
|
||||
def coordinate_bounds(name):
|
||||
def coordinate_bounds(name) -> tuple[float, float]:
|
||||
if type(name) is not str or name not in _COORDINATE_BOUNDS:
|
||||
raise ValueError('unknown canonical coordinate')
|
||||
return _COORDINATE_BOUNDS[name]
|
||||
|
||||
@@ -574,7 +574,7 @@ class ControllerReplay:
|
||||
self.recent_states = OrderedDict()
|
||||
self.cached_validation = None
|
||||
self.cached_key = None
|
||||
self.replay_windows = ()
|
||||
self.replay_windows: tuple[tuple[int, int], ...] = ()
|
||||
self.raw_mode = False
|
||||
self.actual_centre_friction_values = OrderedDict()
|
||||
from openpilot.starpilot.flm.torque_emission_replay import EmissionHistory
|
||||
@@ -1107,7 +1107,7 @@ class ControllerReplay:
|
||||
return None
|
||||
calibration_ids = set(model.train_blocks + model.heldout_blocks)
|
||||
gains = {'left': [], 'right': []}
|
||||
points = {'left': 0, 'right': 0}
|
||||
points: dict[str, float] = {'left': 0, 'right': 0}
|
||||
baseline_controller, baseline_vm = self.factory.create()
|
||||
candidate_controller, candidate_vm = self.factory.create()
|
||||
total_frames = 0
|
||||
@@ -1369,11 +1369,11 @@ class ControllerReplay:
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
|
||||
def to_snapshot(self, blocks=None, *, event_windows=None):
|
||||
def to_snapshot(self, blocks=None, *, event_windows: tuple[tuple[int, int], ...] | None = None):
|
||||
"""Primitive child-fitter payload, outside all100Hz controller processes."""
|
||||
from dataclasses import asdict
|
||||
|
||||
windows = []
|
||||
windows: list[tuple[int, int]] = []
|
||||
if blocks is not None:
|
||||
if not 8 <= len(blocks) <= 24:
|
||||
raise ValueError('fit job maneuver bound')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Child-only canonical effect trials through complete selected controller/CAN history."""
|
||||
|
||||
from typing import cast
|
||||
from collections.abc import Mapping
|
||||
import math
|
||||
|
||||
@@ -15,37 +17,39 @@ POLICY = 'event_effect_coordinate_v2'
|
||||
RESTORE_POLICY = 'event_effect_restore_v1'
|
||||
|
||||
|
||||
class _LazyControllerRequests(Mapping):
|
||||
class _LazyControllerRequests(Mapping[int, float]):
|
||||
"""Only original native replay may pull this child-local controller iterator.
|
||||
|
||||
Values are cached by their exact control timestamp. Native apply-order stays
|
||||
owned by EmissionHistory.rollout, including duplicate/older requested stamps.
|
||||
No result is successful until finish() exhausts all original controller guards.
|
||||
"""
|
||||
def __init__(self, values, frames):
|
||||
self.values, self.frames = values, frames
|
||||
_values: dict[int, float]
|
||||
|
||||
def __init__(self, values: dict[int, float], frames):
|
||||
self._values, self.frames = values, frames
|
||||
self.finished = False
|
||||
|
||||
def __contains__(self, stamp):
|
||||
while stamp not in self.values and not self.finished:
|
||||
while stamp not in self._values and not self.finished:
|
||||
try:
|
||||
next(self.frames)
|
||||
except StopIteration:
|
||||
self.finished = True
|
||||
return stamp in self.values
|
||||
return stamp in self._values
|
||||
|
||||
def __getitem__(self, stamp):
|
||||
if stamp not in self:
|
||||
raise KeyError(stamp)
|
||||
return self.values[stamp]
|
||||
return self._values[stamp]
|
||||
|
||||
def __iter__(self):
|
||||
self.finish()
|
||||
return iter(self.values)
|
||||
return iter(self._values)
|
||||
|
||||
def __len__(self):
|
||||
self.finish()
|
||||
return len(self.values)
|
||||
return len(self._values)
|
||||
|
||||
def close(self):
|
||||
self.frames.close()
|
||||
@@ -247,7 +251,7 @@ class EventControllerReplay:
|
||||
not restore_target and restore_omit_group is None and _prefix_context is not None)
|
||||
build_prefix = bool(prefix_eligible and _prefix_context[1] and not shadow and tuple(coordinates) == self.reference)
|
||||
certificate = self.__dict__.get('_EventControllerReplay__controller_prefix') if prefix_eligible and not build_prefix else None
|
||||
if certificate is not None and certificate[0] != _prefix_context[0]:
|
||||
if certificate is not None and certificate[0] != cast(tuple[object, bool], _prefix_context)[0]:
|
||||
certificate = None
|
||||
prefix_requests, prefix_saturated, prefix_states = {}, {}, {}
|
||||
|
||||
@@ -359,7 +363,7 @@ class EventControllerReplay:
|
||||
# original final-window/tick guard must ALSO finish before any result exists.
|
||||
lazy_requests.finish()
|
||||
if build_prefix and prefix_requests and prefix_states:
|
||||
self.__pending_controller_prefix = (_prefix_context[0], MappingProxyType(prefix_requests),
|
||||
self.__pending_controller_prefix = (cast(tuple[object, bool], _prefix_context)[0], MappingProxyType(prefix_requests),
|
||||
MappingProxyType(prefix_saturated), MappingProxyType(prefix_states))
|
||||
return requests, encoded, saturated
|
||||
finally:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Actual child dispatch for canonical full30 event trials, never a helper-only path."""
|
||||
|
||||
from typing import TypedDict, cast
|
||||
|
||||
|
||||
from openpilot.starpilot.flm.clipping_quality import event_clipping_quality
|
||||
from dataclasses import asdict
|
||||
import itertools
|
||||
@@ -14,6 +18,13 @@ from openpilot.starpilot.flm.torque_emission_replay import delivered
|
||||
from openpilot.starpilot.flm.torque_raw_fit import output_from_dict
|
||||
|
||||
|
||||
class _EffectObjectiveFields(TypedDict):
|
||||
group: str
|
||||
names: tuple[str, ...]
|
||||
anchor_digest: str
|
||||
context_key: str
|
||||
|
||||
|
||||
def _identified(engine, raw):
|
||||
# Selection uses actual feature support ONLY, before any target/loss. A
|
||||
# globally excited column cannot qualify episodes that lack that column.
|
||||
@@ -76,7 +87,7 @@ def fit_event_snapshot(payload, factory, replay_payload):
|
||||
raise ValueError('whole-map activation is restore-only')
|
||||
if payload['event_version'] == 3 and (payload.get('event_operation') != 'trial' or envelope.operation != 'trial'):
|
||||
raise ValueError('declared activation requires original trial operation')
|
||||
objective = EffectObjective(**{**payload['event_objective'], 'names': tuple(payload['event_objective']['names'])})
|
||||
objective = EffectObjective(**cast(_EffectObjectiveFields, {**payload['event_objective'], 'names': tuple(payload['event_objective']['names'])}))
|
||||
if (envelope.trial_names != objective.names or envelope.trial_anchor != objective.anchor_digest or
|
||||
envelope.context_key != objective.context_key or envelope.source_epoch != objective.context_key or
|
||||
envelope.selected_baseline_revision != replay_payload['baseline_revision']):
|
||||
|
||||
@@ -5,6 +5,8 @@ updates use prior-full-hash CAS and native Params byte-exact commit. Never retur
|
||||
an active Profile, publication lease, native permission or restored fit receipt.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import hashlib
|
||||
from openpilot.starpilot.flm.full_surface_profile import (
|
||||
@@ -91,7 +93,7 @@ def remember_full(raw, profile, *, validation_sha256):
|
||||
# to full surface coordinates or treated as live/accepted new-policy evidence.
|
||||
if doc['version'] == 1:
|
||||
doc = {'version': 2, 'profiles': doc['profiles'], NAMESPACE: {}}
|
||||
rows = doc[NAMESPACE]
|
||||
rows = cast(dict[str, dict[str, object]], doc[NAMESPACE])
|
||||
# Stable settings hashes are opaque: a different reset may also represent a
|
||||
# different saved configuration. Never evict another scope by guessing that
|
||||
# only reset changed; the fixed store bound fails closed when full.
|
||||
@@ -119,7 +121,7 @@ def remember_full(raw, profile, *, validation_sha256):
|
||||
raise ValueError('full profile store full')
|
||||
rows[envelope.scope()] = {
|
||||
'current': envelope_dict(envelope),
|
||||
'previous': None if previous is None else {'envelope': envelope_dict(previous), 'validation': row['validation']},
|
||||
'previous': None if previous is None else {'envelope': envelope_dict(previous), 'validation': cast(dict[str, object], row)['validation']},
|
||||
'validation': validation_sha256,
|
||||
}
|
||||
return encode(doc)
|
||||
|
||||
@@ -5,6 +5,8 @@ Inputs are decoded custom evidence produced by actual owners; no Params/default
|
||||
controller selection is inferred here. Domain adapters qualify response inverse.
|
||||
"""
|
||||
|
||||
from typing import TypeGuard, cast
|
||||
|
||||
from collections import Counter, OrderedDict, deque
|
||||
from openpilot.starpilot.flm.torque_eligibility import asynchronous_hint, bounded_context_output, exact_unclipped_pair, consumed_sources_current
|
||||
|
||||
@@ -30,8 +32,8 @@ MAX_WIRE_BYTES = 32768
|
||||
MAX_SNAPSHOT_WIRE_BYTES = 131072
|
||||
|
||||
|
||||
def finite(value):
|
||||
return type(value) in (int, float) and math.isfinite(value)
|
||||
def finite(value: object) -> TypeGuard[int | float]:
|
||||
return (type(value) is int or type(value) is float) and math.isfinite(value)
|
||||
|
||||
|
||||
def replay_values(value, bound):
|
||||
@@ -596,7 +598,8 @@ class FullrateCollector:
|
||||
control = self.raw_points[0].control
|
||||
self.blocks.append(
|
||||
RawBlock(
|
||||
control.identity, f'{self.session}:{self.block_sequence}', self.cohort, control.stage_revision, control.response_revision, tuple(self.raw_points)
|
||||
control.identity, f'{self.session}:{self.block_sequence}', cast(str, self.cohort),
|
||||
control.stage_revision, control.response_revision, tuple(self.raw_points)
|
||||
)
|
||||
)
|
||||
self.block_sequence += 1
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Bounded historical profile storage and current application display."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from openpilot.starpilot.flm.adaptive_profile import ApplicationOutcome
|
||||
import json
|
||||
import hashlib
|
||||
@@ -65,7 +68,7 @@ def decode(raw):
|
||||
or len(parameter) != 2
|
||||
or any(type(x) is not str or not x or len(x) > 128 for x in parameter)
|
||||
or parameter[0] in names
|
||||
or type(value) not in (int, float)
|
||||
or (type(value) is not int and type(value) is not float)
|
||||
or not math.isfinite(value)
|
||||
):
|
||||
raise ValueError('Invalid stored coordinate')
|
||||
@@ -239,7 +242,7 @@ class ProducerPersistence:
|
||||
return False
|
||||
|
||||
def authorized():
|
||||
return current_sources() == captured and (full_binding is None or current_full_binding() == full_binding)
|
||||
return current_sources() == captured and (full_binding is None or cast(Callable[[], object], current_full_binding)() == full_binding)
|
||||
|
||||
committed = self.storage.save(profile, reset=reset, replay_validation=replay_validation, authorized=authorized, full_binding=full_binding)
|
||||
if committed:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Raw parent recordings, one bounded child fit, real selected-controller replay."""
|
||||
|
||||
from typing import TypedDict, cast
|
||||
|
||||
|
||||
import base64
|
||||
from openpilot.starpilot.flm.physical_response import request_force_accel
|
||||
from itertools import islice
|
||||
@@ -23,6 +26,14 @@ from openpilot.starpilot.flm.controller_replay import (
|
||||
factory_from_snapshot,
|
||||
)
|
||||
|
||||
|
||||
class _EffectObjectiveFields(TypedDict):
|
||||
group: str
|
||||
names: tuple[str, ...]
|
||||
anchor_digest: str
|
||||
context_key: str
|
||||
|
||||
|
||||
RAW_TORQUE = 'torque-selected-stage-snapshot-v1'
|
||||
FF_KEYS = tuple(('feedforward', speed, phase) for speed in ('low', 'high') for phase in ('steady', 'turn_in', 'unwind'))
|
||||
|
||||
@@ -464,7 +475,7 @@ def fit_snapshot(snapshot):
|
||||
if event_job:
|
||||
from openpilot.starpilot.flm.event_objective import EffectObjective
|
||||
objective_row = payload['event_objective']
|
||||
event_objective = EffectObjective(**{**objective_row, 'names': tuple(objective_row['names'])})
|
||||
event_objective = EffectObjective(**cast(_EffectObjectiveFields, {**objective_row, 'names': tuple(objective_row['names'])}))
|
||||
# A fixed historical full-map restore is not a friction-only trial. It
|
||||
# must retain independent qualification for every active group, including
|
||||
# FF; choosing a friction protocol marker cannot bypass its noise gate.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Typed custom child snapshots; legacy Event fields and linear fit unchanged."""
|
||||
|
||||
from typing import TypedDict, cast
|
||||
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from capnp.lib.capnp import _DynamicStructReader
|
||||
@@ -7,6 +10,29 @@ from openpilot.cereal import custom, messaging
|
||||
from openpilot.starpilot.flm.adaptive_profile import Parameter, Profile, ReplayValidation, replay_accepts
|
||||
from openpilot.starpilot.flm.fullrate_collector import ControlEvidence, AppliedEvidence, identity, finite
|
||||
|
||||
|
||||
class _FullSurfaceFields(TypedDict):
|
||||
version: int
|
||||
manifest_sha256: str
|
||||
full_original_anchor: str
|
||||
trial_anchor: str
|
||||
context_key: str
|
||||
cp_sha256: str
|
||||
controller_revision: str
|
||||
source_epoch: str
|
||||
reset_generation: str
|
||||
selected_baseline_revision: str
|
||||
prior_full_profile_sha256: str
|
||||
full_profile_sha256: str
|
||||
prior_coordinates: tuple[tuple[str, float], ...]
|
||||
coordinates: tuple[tuple[str, float], ...]
|
||||
trial_names: tuple[str, ...]
|
||||
persistent_source_epoch: str
|
||||
operation: str
|
||||
history_full_profile_sha256: str
|
||||
restore_transaction_sha256: str
|
||||
|
||||
|
||||
VERSION = 1
|
||||
MAX_FIELDS = 8
|
||||
MAX_IDS = 24
|
||||
@@ -70,7 +96,8 @@ def _read_effect_trace(row):
|
||||
raise ValueError('effect trace original envelope')
|
||||
values=[tuple((str(v.name),float(v.value)) for v in getattr(row,n)) for n in ('before','target','applied')]
|
||||
return FullEffectTrace(1,envelope,int(row.controlMonoTime),int(row.previousMonoTime),bool(row.qualified),
|
||||
*values,str(row.appliedFullProfileSha256)).validate()
|
||||
*cast(tuple[tuple[tuple[str, float], ...], tuple[tuple[str, float], ...], tuple[tuple[str, float], ...]], values),
|
||||
str(row.appliedFullProfileSha256)).validate()
|
||||
|
||||
|
||||
def write_control(parent, evidence):
|
||||
@@ -279,11 +306,11 @@ def _read_full_surface(row):
|
||||
try:
|
||||
if row.version not in (1, 2) or len(row.priorCoordinates) != 30 or len(row.coordinates) != 30 or not 1 <= len(row.trialNames) <= 4:
|
||||
return None
|
||||
values = {field: str(getattr(row, wire)) for wire, field in _FULL_FIELDS}
|
||||
values: dict[str, object] = {field: str(getattr(row, wire)) for wire, field in _FULL_FIELDS}
|
||||
values.update(version=int(row.version), prior_coordinates=tuple((str(point.name), float(point.value)) for point in row.priorCoordinates),
|
||||
coordinates=tuple((str(point.name), float(point.value)) for point in row.coordinates),
|
||||
trial_names=tuple(str(name) for name in row.trialNames))
|
||||
return FullSurfaceEnvelope(**values).validate()
|
||||
return FullSurfaceEnvelope(**cast(_FullSurfaceFields, values)).validate()
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ def error_for_frame(parent, cs, baseline_error, *, active, limited):
|
||||
source = getattr(parent, 'automatic_flm_effects', None)
|
||||
if (
|
||||
source is None
|
||||
or observation is None
|
||||
or frame is None
|
||||
or not getattr(parent, 'automatic_pid_stage_admitted', False)
|
||||
or not frame.valid()
|
||||
@@ -222,6 +223,7 @@ def finish_frame(parent, cs, *, desired_angle_deg, actual_angle_deg, baseline_ou
|
||||
if (
|
||||
source is None
|
||||
or not getattr(parent, 'automatic_pid_stage_admitted', False)
|
||||
or observation is None
|
||||
or frame is None
|
||||
or not frame.valid()
|
||||
or not active
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Exact bounded CarController emission metadata; no permission or plant claim."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
FIELDS = (
|
||||
('version', 'version'),
|
||||
('family', 'family'),
|
||||
@@ -30,7 +32,7 @@ def write_projection(value):
|
||||
from opendbc.car.steering_projection import projection_to_dict
|
||||
from openpilot.starpilot.flm.learning_wire import _named_values
|
||||
|
||||
data = projection_to_dict(value)
|
||||
data = cast(dict[str, object], projection_to_dict(value))
|
||||
result = {wire: data[key] for wire, key in FIELDS}
|
||||
# Honda's normalized limiter can have no unlimited integer command: its
|
||||
# lookup encoder runs AFTER limiting. Preserve absence rather than inventing
|
||||
@@ -38,8 +40,8 @@ def write_projection(value):
|
||||
result['version'] = 2
|
||||
result['unlimitedRawPresent'] = data['unlimited_raw'] is not None
|
||||
result['unlimitedRaw'] = 0 if data['unlimited_raw'] is None else data['unlimited_raw']
|
||||
result['limits'] = [{'name': name, 'value': amount} for name, amount in data['limits']]
|
||||
result['shape'] = _named_values(tuple((str(index), item) for index, item in enumerate(data['shape'])))
|
||||
result['limits'] = [{'name': name, 'value': amount} for name, amount in cast(tuple[tuple[str, float], ...], data['limits'])]
|
||||
result['shape'] = _named_values(tuple((str(index), item) for index, item in enumerate(cast(tuple[float | bool, ...], data['shape']))))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Restore-only fixed-map declaration; no finite differences or live authority."""
|
||||
|
||||
from typing import TypedDict, cast
|
||||
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
@@ -11,6 +15,19 @@ from openpilot.starpilot.flm.event_activation import EventActivationPlan, EXACT_
|
||||
from openpilot.starpilot.flm.full_surface_profile import FullSurfaceEnvelope
|
||||
|
||||
|
||||
class _ActivationFields(TypedDict):
|
||||
version: str
|
||||
identity: str
|
||||
baseline_revision: str
|
||||
source_epoch: str
|
||||
physical_context: str
|
||||
calibration_train_blocks: tuple[str, ...]
|
||||
calibration_heldout_blocks: tuple[str, ...]
|
||||
cutoff_ns: int
|
||||
stage_blocks: tuple[str, ...]
|
||||
windows: tuple[tuple[int, int, int, int], ...]
|
||||
|
||||
|
||||
def restore_envelope_digest(envelope):
|
||||
if type(envelope) is not FullSurfaceEnvelope or envelope.operation != 'restore' or envelope.version != 2:
|
||||
raise ValueError('typed full-map historical restore envelope')
|
||||
@@ -29,7 +46,7 @@ class WholeMapRestorePlan(EventActivationPlan):
|
||||
raise ValueError('whole-map restore plan version/envelope')
|
||||
# Reuse only the chronology/lineage validator; never decode this as a trial.
|
||||
fields = {name: getattr(self, name) for name in EventActivationPlan.__dataclass_fields__}
|
||||
EventActivationPlan(**{**fields, 'version': EXACT_EMISSION_ACTIVATION_VERSION}).validate()
|
||||
EventActivationPlan(**cast(_ActivationFields, {**fields, 'version': EXACT_EMISSION_ACTIVATION_VERSION})).validate()
|
||||
return self
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Shared actual-selected controller identities; physical fitting stays in learner."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
import hashlib
|
||||
import json
|
||||
@@ -24,11 +26,12 @@ def implementation_revision(controller_type, interface_type):
|
||||
values. Formatting changes conservatively discard stored priors too.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
controller_root = Path(importlib.import_module(controller_type.__module__).__file__).resolve().parent
|
||||
brand_root = Path(importlib.import_module(interface_type.__module__).__file__).resolve().parent
|
||||
controller_root = Path(cast(str, importlib.import_module(controller_type.__module__).__file__)).resolve().parent
|
||||
brand_root = Path(cast(str, importlib.import_module(interface_type.__module__).__file__)).resolve().parent
|
||||
common_root = here.parents[1] / 'common'
|
||||
inherited_roots = tuple(
|
||||
Path(importlib.import_module(base.__module__).__file__).resolve().parent for base in controller_type.__mro__ if base.__module__.startswith('openpilot.')
|
||||
Path(cast(str, importlib.import_module(base.__module__).__file__)).resolve().parent
|
||||
for base in controller_type.__mro__ if base.__module__.startswith('openpilot.')
|
||||
)
|
||||
roots = (here, here.parent / 'lateral', controller_root, brand_root, brand_root.parent, common_root, *inherited_roots)
|
||||
files = tuple(sorted({path for root in roots for path in root.glob('*.py')}))
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
No saved tune/PID/native writes; admission remains in the controls runtime.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from openpilot.starpilot.flm.adaptive_profile import ProfileOwner, ReplayValidation
|
||||
@@ -43,7 +45,7 @@ class EffectTrial:
|
||||
expected = original_anchor(self.selected_baseline_revision, names)
|
||||
if self.objective.anchor_digest != expected:
|
||||
raise ValueError('recentered or foreign effect objective')
|
||||
payload = self.original_anchor
|
||||
payload = cast(dict[str, object], self.original_anchor)
|
||||
fields = {
|
||||
'version',
|
||||
'manifest_sha256',
|
||||
@@ -71,7 +73,7 @@ class EffectTrial:
|
||||
or payload['selected_baseline_revision'] != self.selected_baseline_revision
|
||||
):
|
||||
raise ValueError('effect full-profile context')
|
||||
entries = payload['accumulated']
|
||||
entries = cast(tuple[tuple[str, float], ...], payload['accumulated'])
|
||||
if type(entries) is not tuple or len(entries) != len(NAMES) or tuple(n for n, _ in entries) != NAMES:
|
||||
raise ValueError('exact full30 prior profile required')
|
||||
accumulated = dict(entries)
|
||||
|
||||
@@ -4,6 +4,10 @@ Actual controller baseline and independent physical model adapters are selected
|
||||
from received source-bound evidence, never another process's startup preference.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
from openpilot.starpilot.flm.runtime_adapter_registry import SourceState
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
@@ -100,7 +104,7 @@ def run(
|
||||
*,
|
||||
clock=time.monotonic_ns,
|
||||
reporter=None,
|
||||
publication_source_reader=None,
|
||||
publication_source_reader: Callable[[int], SourceState | None] | None = None,
|
||||
):
|
||||
from openpilot.starpilot.flm.native_health import current_native_health
|
||||
|
||||
@@ -152,7 +156,7 @@ def run(
|
||||
sm.update(0)
|
||||
now = clock()
|
||||
reader = publication_source_reader if publication else source_reader
|
||||
source = reader(now)
|
||||
source = cast(Callable[[int], SourceState | None], reader)(now)
|
||||
now = clock()
|
||||
known = _source_config_known(source, now)
|
||||
if known and (not source.enabled or source.manual):
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""The desktop report joins real local-file admission, decompression and events."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import zstandard as zstd
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.starpilot.flm import analyze
|
||||
from openpilot.starpilot.flm.local_logs import LocalLogUnavailable
|
||||
|
||||
|
||||
SEGMENT = "1234abcd--0123456789--0"
|
||||
|
||||
|
||||
def test_desktop_report_for_missing_controller_is_honest_and_source_bound(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(analyze, "PC", True)
|
||||
folder = tmp_path / SEGMENT
|
||||
folder.mkdir()
|
||||
event = messaging.new_message("carState", valid=True)
|
||||
event.logMonoTime = 100
|
||||
(folder / "rlog.zst").write_bytes(zstd.compress(event.to_bytes()))
|
||||
assert analyze.main(["--log-root", str(tmp_path.resolve()), SEGMENT]) == 0
|
||||
report = json.loads(capsys.readouterr().out)
|
||||
assert report["purpose"] == "offline_tracking_diagnostics"
|
||||
assert report["vehicleQualification"] is False and report["tuneRecommendation"] is None
|
||||
segment = report["segments"][0]
|
||||
assert segment["analysis"]["status"] == "missing_car_params"
|
||||
assert segment["analysis"]["mean_abs_error"] is None
|
||||
assert len(segment["source"]["sha256"]) == 64
|
||||
assert segment["source"]["segmentName"] == SEGMENT
|
||||
output = tmp_path / "report.json"
|
||||
args = ["--log-root", str(tmp_path.resolve()), "--output", str(output), SEGMENT]
|
||||
assert analyze.main(args) == 0
|
||||
assert json.loads(output.read_text()) == report
|
||||
original = output.read_bytes()
|
||||
assert analyze.main(args) == 1
|
||||
assert output.read_bytes() == original
|
||||
|
||||
|
||||
def test_device_duplicate_and_failed_second_segment_never_emit_partial_report(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(analyze, "PC", False)
|
||||
with pytest.raises(ValueError, match="desktop"):
|
||||
analyze.analyze_local(tmp_path.resolve(), [SEGMENT])
|
||||
monkeypatch.setattr(analyze, "PC", True)
|
||||
with pytest.raises(ValueError, match="distinct"):
|
||||
analyze.analyze_local(tmp_path.resolve(), [SEGMENT, SEGMENT])
|
||||
folder = tmp_path / SEGMENT
|
||||
folder.mkdir()
|
||||
event = messaging.new_message("carState", valid=True)
|
||||
(folder / "rlog.zst").write_bytes(zstd.compress(event.to_bytes()))
|
||||
assert analyze.main(["--log-root", str(tmp_path.resolve()), SEGMENT, "1234abcd--0123456789--1"]) == 1
|
||||
assert capsys.readouterr().out == ""
|
||||
with pytest.raises(LocalLogUnavailable):
|
||||
analyze.analyze_local(tmp_path.resolve(), [SEGMENT], cancelled=lambda: True)
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Immutable bounded caches preserve original digest and reject mutated maps."""
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from typing import cast
|
||||
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
@@ -75,7 +78,7 @@ def test_immutable_parameter_tuple_cache_and_fixed_bounds():
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
first[0].high = 0.9
|
||||
with pytest.raises(TypeError):
|
||||
a._COORDINATE_BOUNDS['center_taper_max'] = (0.0, 0.9)
|
||||
cast(MutableMapping[str, tuple[float, float]], a._COORDINATE_BOUNDS)['center_taper_max'] = (0.0, 0.9)
|
||||
assert a.coordinate_bounds('center_taper_max') == (0.0, 0.18)
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ class ClockedBoundaryAdapter(BoundaryAdapter, EventCollectionAdapter):
|
||||
raw_collection = False
|
||||
decisions = None
|
||||
|
||||
def physical_observable(self, row=None, *, value=None):
|
||||
return super().physical_observable(row if value is None else value)
|
||||
|
||||
def current_admission(self, profile, control, output, context, *, now_ns=None):
|
||||
self.decisions = now_ns
|
||||
return BoundaryAdapter.current_admission(self, profile, control, output, context)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Root-only actual Card/Controls boundaries; no optional failure masks core errors."""
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -49,17 +50,17 @@ def card_fixture():
|
||||
sm.update_msgs(NOW / 1e9, [control.as_reader(), pandas.as_reader()])
|
||||
card = Car.__new__(Car)
|
||||
card.CP, card.CI, card.sm = cp, ci, sm
|
||||
card.pm = Sink()
|
||||
cast(SimpleNamespace, card).pm = Sink()
|
||||
card.vehicle_startup = VehicleStartupOwner()
|
||||
card.ci_initialized, card.ioniq6_long_prearmed = True, False
|
||||
card.ioniq6_keepalive = None
|
||||
card.aol_replay = card.slc_replay = card.curve_replay = card.conditional_replay = False
|
||||
card.car_params_published = False
|
||||
card.can_rcv_cum_timeout_counter = 0
|
||||
card.rk = SimpleNamespace(remaining=0.0)
|
||||
cast(SimpleNamespace, card).rk = SimpleNamespace(remaining=0.0)
|
||||
card.can_log_mono_time = NOW
|
||||
card.last_actuators_output = structs.CarControl.Actuators()
|
||||
card.v_cruise_helper = SimpleNamespace(slc_cruise_change=None)
|
||||
cast(SimpleNamespace, card).v_cruise_helper = SimpleNamespace(slc_cruise_change=None)
|
||||
card.car_gps_publisher = CarGpsPublisher()
|
||||
card.learning_stamp_observer = card.record_learning_send_stamp
|
||||
card.learning_car_state_source_ns = NOW - 1_000_000
|
||||
|
||||
@@ -44,7 +44,7 @@ def capture(controls):
|
||||
startup_rejections += 1
|
||||
continue
|
||||
return evidence, source, startup_rejections
|
||||
pytest.fail('actual source-bound controller checkpoint was not captured')
|
||||
raise pytest.fail.Exception('actual source-bound controller checkpoint was not captured')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('platform,mode,domain', [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Actual parent selection/collector/reporter; synthetic goals confer no fit proof."""
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -21,16 +22,16 @@ def actual_selection_collector(count, *, checkpoint=True):
|
||||
desired_physical_accel=goal) for tick, goal in enumerate(goals))
|
||||
adapter = EventCollectionAdapter.__new__(EventCollectionAdapter)
|
||||
adapter.envelope = object()
|
||||
adapter.event_objective = SimpleNamespace(context_key='context', required_cohorts=('whole',))
|
||||
adapter.objective = SimpleNamespace(required_cohorts=())
|
||||
cast(SimpleNamespace, adapter).event_objective = SimpleNamespace(context_key='context', required_cohorts=('whole',))
|
||||
cast(SimpleNamespace, adapter).objective = SimpleNamespace(required_cohorts=())
|
||||
adapter.key = ('event', 'center')
|
||||
adapter.snapshot_readiness = None
|
||||
adapter.model = None
|
||||
adapter.replay = SimpleNamespace(records=frames, replay_windows=(), _frame_context=lambda _frame: True,
|
||||
cast(SimpleNamespace, adapter).replay = SimpleNamespace(records=frames, replay_windows=(), _frame_context=lambda _frame: True,
|
||||
checkpoints=((NOW, None),) if checkpoint else (), identity=control.identity, baseline_revision='baseline')
|
||||
collector = EventFullrateCollector(adapter)
|
||||
collector.context = ('source',)
|
||||
collector.physical_context = ('physical',)
|
||||
cast(SimpleNamespace, collector).context = ('source',)
|
||||
cast(SimpleNamespace, collector).physical_context = ('physical',)
|
||||
collector.ready.append((control, output))
|
||||
points = tuple(RawPoint(frame.control_ns, frame.control_ns + 1, frame.control_ns + 2,
|
||||
control, output, {}, ('physical',)) for frame in frames[:-60])
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.starpilot.flm import operation_worker as worker
|
||||
|
||||
|
||||
def test_unlimited_linux_limits_get_finite_ceiling():
|
||||
with patch.object(worker.sys, "platform", "linux"), patch.object(worker.os, "nice"), \
|
||||
patch.object(worker.resource, "getrlimit", return_value=(-1, -1)), \
|
||||
patch.object(worker.resource, "setrlimit") as set_limit:
|
||||
worker._limits()
|
||||
assert set_limit.call_args_list[0].args == (worker.resource.RLIMIT_AS, (worker.LINUX_ADDRESS_SPACE_BYTES, -1))
|
||||
assert set_limit.call_args_list[1].args == (worker.resource.RLIMIT_CPU, (worker.LINUX_CPU_SECONDS, -1))
|
||||
|
||||
|
||||
def test_existing_tighter_linux_limits_are_preserved():
|
||||
with patch.object(worker.sys, "platform", "linux"), patch.object(worker.os, "nice"), \
|
||||
patch.object(worker.resource, "getrlimit", side_effect=[(1000000, 2000000), (-1, 60)]), \
|
||||
patch.object(worker.resource, "setrlimit") as set_limit:
|
||||
worker._limits()
|
||||
assert set_limit.call_args_list[0].args == (worker.resource.RLIMIT_AS, (1000000, 2000000))
|
||||
assert set_limit.call_args_list[1].args == (worker.resource.RLIMIT_CPU, (60, 60))
|
||||
@@ -4,6 +4,8 @@ Parent record() copies bounded typed metadata only. CAN decoding, encoder/filter
|
||||
steps and finite differences belong to the child or existing bounded4Hz probe.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, asdict
|
||||
from types import SimpleNamespace
|
||||
@@ -247,7 +249,7 @@ class EncoderBranch:
|
||||
if witness.shape:
|
||||
from opendbc.car.honda.modified_civic_steering import get_civic_bosch_modified_steering_pressed
|
||||
|
||||
lpf, previous, filtered, was_pressed, speed, pressed, driver = shape
|
||||
lpf, previous, filtered, was_pressed, speed, pressed, driver = cast(tuple[float, float, float, bool, float, bool, float], shape)
|
||||
filtered, pressed = get_civic_bosch_modified_steering_pressed(pressed, driver, float32(request), filtered, was_pressed)
|
||||
if pressed:
|
||||
raise ValueError('candidate driver override')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Bounded read-only RAM telemetry; never restores a learned profile."""
|
||||
|
||||
from typing import cast
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -229,8 +231,8 @@ class TrainingReporter:
|
||||
}
|
||||
if collector is not None and getattr(collector.adapter, 'event_collection', False):
|
||||
readiness_reader = getattr(collector, 'snapshot_readiness_report', None)
|
||||
readiness = readiness_reader() if callable(readiness_reader) else {'reason': 'snapshot_not_attempted'}
|
||||
stamp = readiness.pop('observedMonoTime', None)
|
||||
readiness: dict[str, object] = readiness_reader() if callable(readiness_reader) else {'reason': 'snapshot_not_attempted'}
|
||||
stamp = cast(int | None, readiness.pop('observedMonoTime', None))
|
||||
readiness['ageSeconds'] = None if stamp is None else max(0., (now_ns - stamp) / 1e9)
|
||||
details.update({'Snapshot readiness': readiness,
|
||||
'Snapshot selection attempts (collector lifetime)': getattr(collector, 'snapshot_attempts', 0),
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from typing import TypedDict
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
|
||||
@@ -15,6 +16,12 @@ from openpilot.starpilot.ui.onroad_customization import PARAM_KEY, default_docum
|
||||
from openpilot.starpilot.ui.tests.test_layout_preview_transport import png
|
||||
|
||||
|
||||
class PreviewPayload(TypedDict):
|
||||
document: dict
|
||||
profile: str
|
||||
scene: str
|
||||
|
||||
|
||||
class TestLayoutPreviewHttp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
@@ -34,7 +41,7 @@ class TestLayoutPreviewHttp(unittest.TestCase):
|
||||
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': .01}, daemon=True)
|
||||
self.worker.start()
|
||||
self.addCleanup(self.stop)
|
||||
self.payload = {'document': default_document(), 'profile': 'large', 'scene': 'aol'}
|
||||
self.payload: PreviewPayload = {'document': default_document(), 'profile': 'large', 'scene': 'aol'}
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import * as documentHelpers from "../web/js/onroad-layout-document.js"
|
||||
import * as layoutModule from "../web/js/onroad-layout.js"
|
||||
import { ONROAD_LAYOUT_TEMPLATE } from "../web/js/onroad-layout-template.js"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { LayoutWidgetPreview, OnroadLayoutFeed, OnroadLayoutPage, validSnapshot, validDocument, clampPlacement, previewPoint, withAlpha, widgetPalette,
|
||||
withDisplay, layoutDocument, previewDocument } from "../web/js/onroad-layout.js"
|
||||
@@ -804,10 +805,13 @@ assert.ok(appSource.includes('@target="go($event ==='))
|
||||
assert.match(appSource, /goFromMenu\(path\)/)
|
||||
assert.match(appSource, /requestLeave\(proceed, presentation\)/)
|
||||
assert.match(appSource, /@click="goFromMenu\(item\.path\)"/)
|
||||
assert.match(editorSource, /<LayoutWidgetPreview :widget="widget"/)
|
||||
assert.doesNotMatch(editorSource, /<LayoutWidgetPreview v-if="!state\.preview\.url"/)
|
||||
assert.match(editorSource, /<image v-if="nativeBackdropReady" :href="state\.backdrop\.url"/)
|
||||
assert.equal(OnroadLayoutPage.template, ONROAD_LAYOUT_TEMPLATE, "the page uses the extracted template")
|
||||
assert.equal(OnroadLayoutPage.components.LayoutWidgetPreview, LayoutWidgetPreview, "the template preview uses the actual component")
|
||||
assert.match(OnroadLayoutPage.template, /<LayoutWidgetPreview :widget="widget"/)
|
||||
assert.doesNotMatch(OnroadLayoutPage.template, /<LayoutWidgetPreview v-if="!state\.preview\.url"/)
|
||||
assert.match(OnroadLayoutPage.template, /<image v-if="nativeBackdropReady" :href="state\.backdrop\.url"/)
|
||||
assert.doesNotMatch(editorSource, /devicePreviewOpen|gx-layout__device|selectPreview/)
|
||||
assert.doesNotMatch(OnroadLayoutPage.template, /devicePreviewOpen|gx-layout__device|selectPreview/)
|
||||
const loadedCatalog = await loadCatalog({ fetcher: async (url) => ({ ok: true,
|
||||
json: async () => url.endsWith("catalog.json") ? catalog : { schemaVersion: 1, monitor: "local" },
|
||||
}) })
|
||||
|
||||
@@ -49,8 +49,9 @@ class TestSavedTorquePage(unittest.TestCase):
|
||||
learning = rows['Use StarPilot Auto Learn']
|
||||
self.assertTrue(learning['available'])
|
||||
self.assertEqual(learning['value'], 'Off')
|
||||
self.assertIn('StarPilot Controller uses its vehicle tune', learning['reason'])
|
||||
self.assertIn('automatic learning is currently unavailable', learning['reason'])
|
||||
self.assertTrue(learning['reason'].startswith(
|
||||
'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.'))
|
||||
self.assertIn('controller eligibility are initialized when driving processes start', learning['reason'])
|
||||
cp = self.context.sample().cp
|
||||
assert cp is not None
|
||||
self.assertFalse(learning_allowed(self.params, cp))
|
||||
@@ -61,7 +62,7 @@ class TestSavedTorquePage(unittest.TestCase):
|
||||
self.assertTrue(preference.enabled)
|
||||
self.assertEqual(preference.source, 'scope')
|
||||
self.assertTrue(self.params.get_bool('ForceAutoTuneOff'))
|
||||
self.assertFalse(learning_allowed(self.params, cp), 'Saving On does not admit the selected nonlinear controller')
|
||||
self.assertTrue(learning_allowed(self.params, cp), 'Scoped On allows native refinement with either selected controller')
|
||||
page = self.page()
|
||||
|
||||
index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'Friction')
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
export const ONROAD_LAYOUT_TEMPLATE = `
|
||||
<section class="gx-settings gx-layout" aria-label="Colors and layout" :aria-busy="busy || undefined">
|
||||
<div class="gx-settings__header gx-page-header"><div><h2>{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}</h2></div>
|
||||
<button class="gx-icon-btn gx-layout__info" type="button" :aria-expanded="state.aboutOpen" aria-controls="gx-layout-about" aria-label="About this editor" title="About this editor" @click="state.aboutOpen = !state.aboutOpen"><i class="bi bi-info-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<GxNotice v-if="mode === 'local' && state.data && !state.data.editable && !(projection && state.data.reason)" tone="warn">Park the vehicle and reload to edit. If it stays unavailable, reload saved settings.</GxNotice>
|
||||
<div class="gx-layout__targets">
|
||||
<nav class="gx-layout__tabs gx-layout__segment" aria-label="Layout target">
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="!projection" :disabled="!!state.drag" @click="$emit('target', 'device')">Comma</button>
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="projection" :disabled="!!state.drag" @click="$emit('target', 'projection')">Android Auto</button>
|
||||
</nav>
|
||||
<div v-if="mode === 'local' && state.data && state.draft && !projection && availableProfiles.length" class="gx-layout__tabs gx-layout__segment" role="group" aria-label="Layout size">
|
||||
<button v-for="tab in [{id:'large',label:'Big'}, {id:'compact',label:'Small'}].filter(tab => availableProfiles.includes(tab.id))" :key="tab.id" class="gx-btn gx-btn--tonal" type="button"
|
||||
:aria-pressed="state.profile === tab.id" :disabled="!!state.drag" @click="selectProfile(tab.id)">{{ tab.label }}<span v-if="state.data.activeProfile === tab.id" class="gx-layout__active" title="Active UI on the comma"><span class="gx-sr-only"> (active UI)</span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="state.aboutOpen" id="gx-layout-about" class="gx-layout__about">
|
||||
<p v-if="!projection" class="gx-note">Choose a widget to move it or change its colors. Edit the layout available on this comma.</p><p v-else class="gx-note">Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.</p>
|
||||
<p v-if="projection && state.data?.screen" class="gx-note">Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels.</p>
|
||||
</div>
|
||||
<p v-if="projection && state.data?.reason" class="gx-note">{{ state.data.reason }}</p>
|
||||
<GxState v-if="mode !== 'local'">Connect to local Galaxy to edit this device’s colors and layouts.</GxState>
|
||||
<template v-else>
|
||||
<div ref="leaveBar" class="gx-layout__savebar" :class="{'gx-layout__savebar--leave': inlineLeavePrompt, 'is-stuck': state.stuck.leaveBar}" :inert="busy">
|
||||
<div v-if="snapInBar && !inlineLeavePrompt" class="gx-layout__savebar-snap" aria-hidden="true"><i class="bi bi-magnet" aria-hidden="true"></i>{{ snapSummary }}</div>
|
||||
<span role="status" :class="{'is-dirty': dirty && !inlineLeavePrompt && state.status !== 'saving'}">{{ inlineLeavePrompt ? (pendingLeave ? 'Leave without saving your changes?' : 'Discard your edits and reload the saved colors and layouts?') : state.status === 'saving' ? 'Saving…' : state.status === 'loading' ? 'Loading…' : !state.data ? 'No saved layout loaded' : dirty ? 'Unsaved' : state.notice || 'Saved' }}</span>
|
||||
<template v-if="inlineLeavePrompt">
|
||||
<button class="gx-btn gx-btn--tonal" type="button" aria-label="Keep editing" title="Keep editing" @click="cancelLeave"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12a8 8 0 1 0 3-6M4 4v6h6"/></svg><span class="gx-layout__action-label">Keep editing</span></button>
|
||||
<button class="gx-btn gx-btn--danger" type="button" :aria-label="pendingLeave ? 'Discard and leave' : 'Discard and reload'" :title="pendingLeave ? 'Discard and leave' : 'Discard and reload'" @click="leave(state.discard)"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13"/></svg><span class="gx-layout__action-label">{{ pendingLeave ? 'Discard and leave' : 'Discard and reload' }}</span></button>
|
||||
<button v-if="pendingLeave" class="gx-btn" type="button" :disabled="!saveAvailable" aria-label="Save changes" title="Save changes" @click="saveAndLeave"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg><span class="gx-layout__action-label">{{ state.status === 'saving' ? 'Saving…' : 'Save changes' }}</span></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !canUndo || !!state.drag || !!state.layerDrag" aria-label="Undo" title="Undo" @click="undo"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5 4 10l5 5M4 10h10a6 6 0 0 1 0 12"/></svg><span class="gx-layout__action-label">Undo</span></button>
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !canRedo || !!state.drag || !!state.layerDrag" aria-label="Redo" title="Redo" @click="redo"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 5 5 5-5 5m5-5H10a6 6 0 0 0 0 12"/></svg><span class="gx-layout__action-label">Redo</span></button>
|
||||
<div ref="moreMenu" class="gx-layout__more" @keydown.esc="closeFloating('moreOpen')">
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :aria-expanded="state.moreOpen" aria-haspopup="menu" aria-label="More actions" title="More actions" @click="toggleFloating('moreOpen', 'moreMenu')"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg><span class="gx-layout__action-label">More</span></button>
|
||||
<template v-if="state.moreOpen">
|
||||
<div class="gx-layout__menu" role="menu" aria-label="More actions">
|
||||
<button class="gx-layout__menu-item" type="button" role="menuitem" :disabled="!!state.drag || !!state.layerDrag" @click="closeFloating('moreOpen'); requestLeave('reload')"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 7v5h-5M20 12a8 8 0 1 0-2 5"/></svg>Reload saved</button>
|
||||
<button class="gx-layout__menu-item" type="button" role="menuitem" :disabled="!editAvailable || !stockChanged || !!state.drag || !!state.layerDrag" @click="closeFloating('moreOpen'); resetToStock()"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 11 9-8 9 8M5 10v11h14V10M9 21v-7h6v7"/></svg>Reset to stock StarPilot</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="gx-btn gx-layout__save" type="button" :disabled="!editAvailable || !dirty || !!state.drag || !!state.layerDrag" aria-label="Save changes" title="Save changes" @click="save"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg><span class="gx-layout__action-label gx-layout__save-label">Save<span class="gx-layout__long"> changes</span></span></button>
|
||||
</template>
|
||||
</div>
|
||||
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
|
||||
<template v-if="state.data && state.draft">
|
||||
<p v-if="!state.data.valid" class="gx-note" role="status">{{ projection ? 'Default Android Auto positions are shown. Save to keep your separate layout.' : 'The saved customization is invalid. Default colors and positions are shown; save your edits to replace it.' }}</p>
|
||||
<div class="gx-layout__workspace" :inert="busy">
|
||||
<section ref="pinned" class="gx-card gx-layout__preview-card" :class="{'is-stuck': state.stuck.pinned}" aria-label="Driving screen preview" :style="{'--layout-ratio': profile.width / profile.height}">
|
||||
<div class="gx-layout__subhead"><strong>Layout preview</strong><span>{{ state.layerDrag ? "Adjusting " + selectedWidget.label : state.art.status === 'updating' || state.backdrop.status === 'updating' ? 'Updating preview…' : profile.width + " × " + profile.height }}</span></div>
|
||||
<div class="gx-layout__preview-bar">
|
||||
<GalaxySelect class="gx-field gx-field--full" aria-label="Preview scene" :value="state.scene" :disabled="!!state.drag || !!state.layerDrag" @change="state.scene = $event.target.value">
|
||||
<optgroup v-for="group in sceneGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="scene in group.scenes" :key="scene.id" :value="scene.id">{{ scene.label }}</option>
|
||||
</optgroup>
|
||||
</GalaxySelect>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.snapEnabled" :disabled="!!state.drag || !!state.layerDrag" aria-label="Snap to edges and centers" title="Snap to edges and centers" @click="toggleSnap"><i class="bi bi-magnet" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showEditingAids" aria-label="Show editing aids" title="Show editing aids" @click="toggleEditingAids"><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
|
||||
<button v-if="!projection && profile.inputZones?.length" class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showFavoriteZones" aria-label="Show Favorite tap areas" title="Show Favorite tap areas" @click="state.showFavoriteZones = !state.showFavoriteZones"><i class="bi bi-grid-3x3-gap" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-expanded="state.tipsOpen" aria-controls="gx-layout-tips" aria-label="Preview tips and display behavior" title="Preview tips and display behavior" @click="state.tipsOpen = !state.tipsOpen"><i class="bi bi-question-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<p class="gx-note">{{ state.scene === 'general' ? 'Arrange all enabled widgets with sample readings. Use a driving scene to check when they appear.' : 'Eyes follow this driving scene. Choose General edit to arrange widgets hidden here.' }}</p>
|
||||
<p v-if="state.backdrop.status === 'unavailable'" class="gx-note" role="status">{{ nativeBackdropReady ? 'The comma screen could not refresh. Showing the last one.' : 'The comma screen is unavailable. Showing a sketch.' }}</p>
|
||||
<p v-if="state.art.status === 'unavailable'" class="gx-note" role="status">{{ state.art.url ? 'Widget preview could not refresh. Showing the last preview.' : 'Widget preview unavailable. Placement controls remain available.' }}</p>
|
||||
<div class="gx-layout__stage">
|
||||
<span v-if="state.showEditingAids && state.snapGuides.length && !snapInBar" class="gx-layout__stage-chip gx-layout__stage-chip--start" :class="{'gx-layout__stage-chip--with-tap-areas': !projection && state.showFavoriteZones && state.profile !== 'compact'}" aria-hidden="true">{{ snapSummary }}</span>
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile !== 'compact'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<svg ref="preview" class="gx-layout__preview" :style="{'--layout-glow': state.profile === 'compact' ? '4px' : '14px'}" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
|
||||
@pointermove="moveDrag" @pointerup="endDrag" @pointercancel="cancelDrag" @lostpointercapture="cancelDrag"
|
||||
role="group" :aria-label="projection ? 'Android Auto layout preview' : (state.profile === 'large' ? 'Big' : 'Small') + ' layout preview'">
|
||||
<image v-if="nativeBackdropReady" :href="state.backdrop.url" :width="profile.width" :height="profile.height" preserveAspectRatio="none" data-preview-source="device-backdrop" />
|
||||
<template v-else>
|
||||
<rect width="100%" height="100%" fill="#252b32" />
|
||||
<polygon :points="roadPoints([[0,1],[.4,.45],[.55,.45],[.83,1]])" fill="#303943" />
|
||||
<defs><linearGradient id="layout-road-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
|
||||
<stop v-for="stop in roadGradient" :key="stop.offset" :offset="stop.offset" :stop-color="stop.color" :stop-opacity="stop.alpha" />
|
||||
</linearGradient></defs>
|
||||
<defs><linearGradient id="layout-edge-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
|
||||
<stop offset="0" :stop-color="roadColors.pathEdge.slice(0,7)" stop-opacity="0" />
|
||||
<stop offset=".5" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .35) / 255" />
|
||||
<stop offset="1" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .4) / 255" />
|
||||
</linearGradient></defs>
|
||||
<polygon v-if="state.profile === 'large' && roadStyle.pathEdge" v-for="strip in [[[.18,1],[.44,.45],[.447,.45],[.231,1]], [[.639,1],[.503,.45],[.51,.45],[.69,1]]]"
|
||||
:points="roadPoints(strip)" fill="url(#layout-edge-gradient)" />
|
||||
<polygon :points="roadPoints(state.profile === 'large' && roadStyle.pathEdge ? [[.231,1],[.447,.45],[.503,.45],[.639,1]] : [[.18,1],[.44,.45],[.51,.45],[.69,1]])" fill="url(#layout-road-gradient)" />
|
||||
<path v-for="(line,index) in [[[.16,1],[.43,.45]],[[.71,1],[.52,.45]],[[.02,1],[.38,.45]],[[.85,1],[.57,.45]]]" :key="'road-line-'+index"
|
||||
:d="'M'+roadPoints(line).replace(' ', ' L')"
|
||||
:stroke="state.profile === 'compact' && index < 2 ? (roadStyle.pathEdge || '#00ff40ff') : roadColors.laneLines"
|
||||
opacity=".7" :stroke-width="state.profile === 'compact' ? 2 : 5" />
|
||||
<rect v-if="!projection && !expandedRoad" :x="state.profile === 'large' ? 1860 : 476" y="0" :width="state.profile === 'large' ? 300 : 60" :height="profile.height" fill="#090c10" />
|
||||
<g v-if="!projection && state.profile === 'large' && !expandedRoad" aria-hidden="true" pointer-events="none">
|
||||
<g v-for="(sample, index) in [{label:'ACCEL',value:'1.08 ft/s²',accent:'#ffffff'}, {label:'STEER DELAY',value:'0.15000',accent:'#3b82f6'}, {label:'FRICTION',value:'0.12000',accent:'#22c55e'}, {label:'LAT ACCEL',value:'2.50000',accent:'#3b82f6'}, {label:'LATERAL %',value:'78.00%',accent:'#ffffff'}, {label:'TORQUE %',value:'42%',accent:'#ffffff'}, {label:'CHESTNUT',value:'',accent:'#ffffff'}]" :key="sample.label" :transform="'translate(1873 ' + (24 + index * 150) + ')'">
|
||||
<rect width="275" height="126" rx="16" fill="#000000" stroke="#ffffff55" stroke-width="2" />
|
||||
<rect x="253" y="4" width="18" height="118" rx="4" :fill="sample.accent" />
|
||||
<text x="137" :y="sample.value ? 51 : 75" text-anchor="middle" :font-size="sample.label === 'STEER DELAY' ? 26 : 30" font-weight="600" fill="#ffffff">{{ sample.label }}</text>
|
||||
<text v-if="sample.value" x="137" y="91" text-anchor="middle" font-size="30" font-weight="600" fill="#ffffff">{{ sample.value }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</template>
|
||||
<rect v-if="!nativeBackdropReady" data-preview-source="scene-border"
|
||||
:x="state.profile === 'compact' ? 4 : profile.bounds.x" :y="state.profile === 'compact' ? 4 : profile.bounds.y"
|
||||
:width="state.profile === 'compact' ? 468 : roadBounds.width" :height="state.profile === 'compact' ? 232 : profile.bounds.height"
|
||||
:rx="state.profile === 'compact' ? 14 : 61" fill="none" :stroke="sceneBorder" :stroke-width="state.profile === 'compact' ? 8 : 30" />
|
||||
<rect v-if="state.showEditingAids" :x="roadBounds.x" :y="roadBounds.y" :width="roadBounds.width" :height="roadBounds.height" fill="none" stroke="#8495a855" stroke-width="2" stroke-dasharray="8 8" />
|
||||
<g v-if="!projection && state.showFavoriteZones && state.showEditingAids" aria-hidden="true" pointer-events="none">
|
||||
<g v-for="zone in profile.inputZones || []" :key="zone.id">
|
||||
<rect :x="zone.x" :y="zone.y" :width="zone.width" :height="zone.height" fill="#b799ff16" stroke="#b799ff88" :stroke-width="state.profile === 'large' ? 3 : 1" stroke-dasharray="5 5" />
|
||||
<text :x="zone.x + zone.width / 2" :y="zone.y + zone.height - 8" text-anchor="middle" :font-size="state.profile === 'large' ? 20 : 10" fill="#d8c8ff">{{ zone.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="widget in renderWidgets" :key="widget.id" class="gx-layout__widget" :class="{'is-selected': state.drag?.id === widget.id, 'is-editing': state.showEditingAids && state.inspectorPanel === 'edit' && state.selected === widget.id}"
|
||||
:transform="'translate(' + previewX(widget.id) + ' ' + layout[widget.id].y + ')'" tabindex="0" role="button"
|
||||
:aria-label="widget.label + ', x ' + previewX(widget.id) + ', y ' + layout[widget.id].y +
|
||||
(widget.frontmost ? '. Arrow keys move; Shift moves ten pixels; required and always frontmost widget.' :
|
||||
widget.required ? '. Arrow keys move; Shift moves ten pixels; required widget.' : '. Arrow keys move; Shift moves ten pixels; Delete removes.')"
|
||||
:aria-pressed="state.selected === widget.id" @focus="state.selected = widget.id" @keydown="onKey(widget.id, $event)"
|
||||
@pointerdown="startDrag(widget.id, $event)">
|
||||
<rect class="gx-layout__hit" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="transparent" />
|
||||
<LayoutWidgetPreview :widget="widget" :palette="widgetColors(widget.id)" :profile="state.profile" :scene="state.scene" :clock24-hour="!!state.draft.clock24Hour" :native="runtimeWidget(widget.id)" :art="state.art" />
|
||||
<rect class="gx-layout__selection" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="none" stroke="#b799ff" :stroke-width="state.profile === 'large' ? 5 : 1.5" stroke-dasharray="6 4" />
|
||||
<g v-if="widget.box && state.selected === widget.id && editable" class="gx-layout__resize" role="button" :aria-label="'Resize ' + widget.label"
|
||||
:transform="'translate(' + widget.width + ' ' + widget.height + ')'" @pointerdown="startResize(widget.id, $event)">
|
||||
<circle r="34" fill="transparent" /><circle r="16" fill="#b799ff" stroke="#16121f" stroke-width="4" />
|
||||
<path d="M-6 6 L6 -6 M0 6 L6 0" stroke="#16121f" stroke-width="3" stroke-linecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
<g v-if="projection && !nativeBackdropReady" data-preview-source="fixed-wordmark" role="img" aria-label="StarPilot, fixed logo" pointer-events="none"
|
||||
:transform="'translate(' + (profile.width - projectionWordmark.right) + ' ' + (profile.height - projectionWordmark.bottom) + ') scale(' + projectionWordmark.scale + ')'">
|
||||
<svg v-for="(glyph, index) in projectionWordmark.glyphs" :key="index" :x="glyph.offsetX" :y="glyph.offsetY"
|
||||
:width="glyph.width" :height="glyph.height" :viewBox="[glyph.x, glyph.y, glyph.width, glyph.height].join(' ')" aria-hidden="true">
|
||||
<image href="./assets/starpilot-wordmark.png" width="1024" height="512" />
|
||||
</svg>
|
||||
</g>
|
||||
<g v-if="state.showEditingAids && state.snapGuides.length" class="gx-layout__guides" aria-hidden="true" pointer-events="none">
|
||||
<line v-for="guide in state.snapGuides" :key="guide.axis + guide.value" :class="'gx-layout__guide gx-layout__guide--' + guide.kind"
|
||||
:x1="guide.axis === 'x' ? guide.value + previewShift(state.drag?.id) : 0" :x2="guide.axis === 'x' ? guide.value + previewShift(state.drag?.id) : profile.width"
|
||||
:y1="guide.axis === 'y' ? guide.value : 0" :y2="guide.axis === 'y' ? guide.value : profile.height" vector-effect="non-scaling-stroke" />
|
||||
</g>
|
||||
<rect v-if="state.showEditingAids && selectionBox" class="gx-layout__outline" :x="selectionBox.x" :y="selectionBox.y" :width="selectionBox.width" :height="selectionBox.height" rx="4" fill="none" stroke="#d4baff" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- The Small screen is too short to carry the chip over the layout; it sits underneath instead. -->
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile === 'compact'" class="gx-layout__stage-chip gx-layout__stage-chip--below" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<div v-show="state.tipsOpen" id="gx-layout-tips" class="gx-layout__tips">
|
||||
<p class="gx-note">Drag widgets to place them. Behind them is the comma's own screen for the chosen scene, including its border, road and alerts. Readings are samples.</p>
|
||||
<p class="gx-note">Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
|
||||
<p class="gx-note">General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.</p>
|
||||
<p class="gx-note">The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.</p>
|
||||
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.</p>
|
||||
<p v-if="!projection" class="gx-note">Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.</p>
|
||||
<p v-if="!projection && state.showFavoriteZones" class="gx-note">Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.</p>
|
||||
</div>
|
||||
<p v-if="state.placementError" class="gx-note" role="status">{{ state.placementError }}</p>
|
||||
</section>
|
||||
<section class="gx-card gx-layout__inspector" aria-label="Widget placement" @focusin="revealField"> <!-- codespell:ignore focusin -->
|
||||
<nav ref="sections" class="gx-layout__switcher gx-layout__sections gx-settings-tabs" :class="{'is-stuck': state.stuck.sections}" aria-label="Editor sections">
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'widgets'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'widgets'">Widgets</button>
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'edit'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'edit'"><span>Edit<span class="gx-layout__long"> widget</span></span></button>
|
||||
<button v-if="!projection" class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'road'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'road'"><span>Road<span class="gx-layout__long"> colors</span></span></button>
|
||||
<button v-if="projection || displayGroups.length" class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'display'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'display'">Options</button>
|
||||
</nav>
|
||||
<div v-show="state.inspectorPanel === 'widgets'" class="gx-layout__panel">
|
||||
<div ref="widgetsHelp" class="gx-layout__subhead gx-layout__help-anchor"><h3>Widgets</h3><span>{{ activeWidgets.length }} active</span>
|
||||
<button class="gx-icon-btn gx-layout__info" type="button" :aria-expanded="state.widgetsHelpOpen" aria-controls="gx-layout-widgets-help" aria-label="How the widget list works" title="How the widget list works" @click="toggleFloating('widgetsHelpOpen', 'widgetsHelp')"><i class="bi bi-info-circle" aria-hidden="true"></i></button>
|
||||
<div v-show="state.widgetsHelpOpen" id="gx-layout-widgets-help" class="gx-layout__popover" @click="closeFloating('widgetsHelpOpen')" @keydown.esc="closeFloating('widgetsHelpOpen')">
|
||||
<p>Tap a widget to edit it.</p><p>Drag ⠿ to reorder. The top row is in front, and alerts stay above all widgets.</p><p>The eyes follow the chosen scene. Choose General edit to arrange every enabled widget. Hiding a widget with the eye affects only this scene's preview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gx-layout__widget-list"><div v-for="widget in listWidgets" :key="widget.id" class="gx-layout__layer" :data-layer-id="widget.id"
|
||||
:class="{'is-dragging': state.layerDrag?.id === widget.id && state.layerDrag?.moved, 'is-drop-target': state.layerDrag?.target === widget.id && state.layerDrag?.id !== widget.id, 'is-drop-after': state.layerDrag?.after}">
|
||||
<button class="gx-layout__row-grip gx-icon-btn" type="button"
|
||||
:aria-label="widget.frontmost ? widget.label + ' is pinned above all widgets' : 'Reorder ' + widget.label"
|
||||
:disabled="!editAvailable || !!state.drag || widget.frontmost"
|
||||
:title="widget.frontmost ? 'Always above other widgets' : 'Drag to reorder. Up and down keys reorder.'"
|
||||
@pointerdown="startLayerDrag($event, widget.id)" @pointermove="moveLayerDrag($event)"
|
||||
@pointerup="endLayerDrag($event)" @pointercancel="endLayerDrag($event, true)" @lostpointercapture="endLayerDrag($event, true)"
|
||||
@keydown="layerKey($event, widget.id)" @click="layerClick($event, widget.id)"><span aria-hidden="true">{{ widget.frontmost ? '↑' : '⠿' }}</span></button>
|
||||
<button class="gx-layout__layer-name" type="button" :aria-pressed="state.selected === widget.id" :disabled="!!state.drag || !!state.layerDrag"
|
||||
@click="selectWidget(widget.id)">{{ widget.label }}</button>
|
||||
<button v-if="!projection || widget.id !== 'car_exit'" class="gx-layout__visibility gx-icon-btn" type="button" :disabled="!!state.drag || !!state.layerDrag || sceneHidden(widget.id)"
|
||||
:aria-label="visibilityTitle(widget.id, widget.label)"
|
||||
:title="visibilityTitle(widget.id, widget.label)"
|
||||
:aria-pressed="!previewHidden(widget.id)" @click="togglePreviewVisibility(widget.id)">
|
||||
<i class="bi" :class="previewHidden(widget.id) ? 'bi-eye-slash' : 'bi-eye'" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button v-if="!widget.required" class="gx-layout__remove gx-icon-btn" type="button" :aria-label="'Remove ' + widget.label" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="requestRemove(widget.id)"><span aria-hidden="true">−</span></button>
|
||||
<span v-else class="gx-note gx-layout__required" title="This control cannot be removed or placed below another widget.">Required</span>
|
||||
</div></div>
|
||||
<Teleport to="body"><div v-if="state.layerDrag?.moved" class="gx-layout__drag-ghost" aria-hidden="true"
|
||||
:style="{left: state.layerDrag.x + 'px', top: state.layerDrag.y + 'px', width: state.layerDrag.width + 'px'}">
|
||||
<span aria-hidden="true">⠿</span><strong>{{ selectedWidget.label }}</strong><span>↕</span>
|
||||
</div></Teleport>
|
||||
<div class="gx-layout__tray" role="group" aria-labelledby="gx-layout-tray-title">
|
||||
<div class="gx-layout__subhead"><h4 id="gx-layout-tray-title">Inactive widgets</h4><span>{{ inactiveWidgets.length }}</span></div>
|
||||
<p v-if="inactiveWidgets.length" class="gx-note">Drag a widget onto the preview, or tap + to add it.</p>
|
||||
<div v-if="inactiveWidgets.length" class="gx-layout__chips">
|
||||
<div v-for="widget in inactiveWidgets" :key="widget.id" class="gx-layout__tray-item">
|
||||
<button class="gx-layout__drag-handle" type="button" :disabled="!editAvailable" :aria-label="'Drag ' + widget.label + ' onto preview'"
|
||||
@pointerdown="startDrag(widget.id, $event, true)"><i class="bi bi-grip-vertical" aria-hidden="true"></i></button><span class="gx-layout__inactive-name">{{ widget.label }}</span>
|
||||
<button class="gx-icon-btn gx-layout__chip-add" type="button" :aria-label="'Add ' + widget.label" :title="'Add ' + widget.label" :disabled="!editAvailable || !!state.drag" @click="add(widget.id)"><i class="bi bi-plus-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="gx-note">All available widgets are on this layout.</p>
|
||||
</div>
|
||||
<button class="gx-btn gx-btn--tonal gx-layout__reset" type="button" :disabled="!editAvailable || !!state.drag" @click="resetLayout">Reset this layout</button>
|
||||
</div>
|
||||
<div v-show="state.inspectorPanel === 'edit'" class="gx-layout__panel">
|
||||
<label class="gx-layout__widget-picker"><span class="gx-layout__picker-head">Selected widget<span v-if="selectedWidget && selectedPosition.enabled" class="gx-note">{{ selectedSize[0] }} × {{ selectedSize[1] + (selectedWidget.box ? 0 : selectedWidget.visualInsetTop || 0) }} px</span></span><GalaxySelect class="gx-field gx-field--full" :value="state.selected" :disabled="!!state.drag" @change="state.selected = $event.target.value">
|
||||
<option v-for="widget in widgets" :key="widget.id" :value="widget.id">{{ widget.label }}{{ layout[widget.id].enabled ? '' : ' · Inactive' }}</option>
|
||||
</GalaxySelect></label>
|
||||
<div v-if="selectedWidget && !selectedPosition.enabled" class="gx-layout__panel"><p class="gx-note">Add this widget to edit its position and colors.</p><button class="gx-btn" :disabled="!editAvailable" @click="add(state.selected)">Add to layout</button></div>
|
||||
<p v-if="selectedWidget && selectedPosition.enabled && sceneHidden(state.selected)" class="gx-note">This widget is hidden in this driving scene. <button class="gx-btn gx-btn--tonal" type="button" :disabled="!!state.drag || !!state.layerDrag" @click="state.scene = 'general'">Edit in General edit</button></p>
|
||||
<div v-if="selectedWidget && selectedPosition.enabled" class="gx-layout__position">
|
||||
<label v-if="selectedWidget.resizable">Size
|
||||
<input class="gx-field" type="number" step="1" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
|
||||
:value="selectedPosition.size" :disabled="!editAvailable || !!state.drag" @change="resizeSelected($event)"></label>
|
||||
<input v-if="selectedWidget.resizable" class="gx-slider" type="range" step="1" aria-label="Size" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
|
||||
:value="selectedPosition.size" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="sizeSlider($event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<template v-if="selectedWidget.opacity">
|
||||
<label for="layout-widget-opacity">{{ selectedWidget.box ? 'Map opacity' : selectedWidget.kind === 'cruise_limits' ? 'Background opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
|
||||
<input id="layout-widget-opacity" class="gx-slider" type="range" step="1" :min="selectedWidget.opacity.min" :max="selectedWidget.opacity.max"
|
||||
:value="selectedPosition.opacity" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="opacityInput($event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
</template>
|
||||
<GalaxySettingRow v-if="selectedWidget.backgroundToggle" :row="switchRow('Show background', selectedPosition.background === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setWidgetBackground(value)" />
|
||||
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
|
||||
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
|
||||
<label v-if="selectedWidget.kind === 'clock'" class="gx-layout__clock-format"><input type="checkbox" :checked="!!state.draft.clock24Hour" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setClock24Hour"> Use 24-hour time</label>
|
||||
<div v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__favorite-display">
|
||||
<span>Text alignment</span>
|
||||
<div class="gx-segment" role="group" aria-label="Cruise and speed limit text alignment">
|
||||
<button v-for="align in ['left', 'center', 'right']" :key="align" class="gx-btn" type="button"
|
||||
:class="(state.draft.speedAlign || 'left') === align ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="(state.draft.speedAlign || 'left') === align" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="setSpeedAlign(align)">{{ align === 'left' ? 'Left' : align === 'center' ? 'Center' : 'Right' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits'" :row="switchRow('Show Speed-Limit Sources', state.draft.speedSources === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSources', value === 'On')" />
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits' && state.draft.speedSources === true" :row="switchRow('Show as Separate Card', state.draft.speedSourcesCard === true)" :index="1"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSourcesCard', value === 'On')" />
|
||||
<label v-if="!projection && selectedWidget.kind === 'cruise_limits'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.showMaxSpeed !== false" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setShowMaxSpeed"> Show MAX speed</label>
|
||||
<GalaxySettingRow v-if="projection && selectedWidget.kind === 'steering_wheel'" :row="switchRow('Brake and Acceleration Colors', state.draft.wheelPedalFeedback === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('wheelPedalFeedback', value)" />
|
||||
<GalaxySettingRow v-if="!projection && selectedWidget.kind === 'steering_wheel' && displayRow('ShowBrakeStatus')" :row="switchRow('Brake and Acceleration Colors', displayValue('ShowBrakeStatus') === 'On')" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('ShowBrakeStatus'))" :save-value="(index, value) => setDisplay('ShowBrakeStatus', value)" />
|
||||
<div v-if="!projection && selectedWidget.kind === 'model_confidence' && displayRow('StockConfidenceBallWidget')" class="gx-layout__favorite-display">
|
||||
<span>Style</span>
|
||||
<div class="gx-segment" role="group" aria-label="Model confidence style">
|
||||
<button v-for="choice in [['On', 'StarPilot widgets'], ['Off', 'Stock confidence ball']]" :key="choice[0]" class="gx-btn" type="button"
|
||||
:class="displayValue('StockConfidenceBallWidget') === choice[0] ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="displayValue('StockConfidenceBallWidget') === choice[0]"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('StockConfidenceBallWidget'))"
|
||||
@click="setDisplay('StockConfidenceBallWidget', choice[0])">{{ choice[1] }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!projection && stockConfidence && selectedWidget.kind === 'conditional_mode'" class="gx-note">Hidden while Model confidence uses the stock confidence ball.</p>
|
||||
<div v-if="projection && selectedWidget.iconSize" class="gx-layout__favorite-display">
|
||||
<span>Show as</span>
|
||||
<div class="gx-segment" role="group" :aria-label="selectedWidget.label + ' display'">
|
||||
<button v-for="display in ['icons', 'words']" :key="display" class="gx-btn" type="button"
|
||||
:class="selectedPosition.display === display ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="selectedPosition.display === display" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="setFavoriteDisplay(display)">{{ display === 'icons' ? 'Icons' : 'Words' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gx-layout__coordinates"><label>X<input class="gx-field" type="number" step="1" :min="limits.minX" :max="limits.maxX" :value="selectedPosition.x" :disabled="!editAvailable || !!state.drag" @change="positionInput('x', $event)"></label>
|
||||
<label>Y<input class="gx-field" type="number" step="1" :min="limits.minY" :max="limits.maxY" :value="selectedPosition.y" :disabled="!editAvailable || !!state.drag" @change="positionInput('y', $event)"></label><template v-if="selectedWidget.box">
|
||||
<label>Width<input class="gx-field" type="number" step="10" :min="selectedWidget.box.minWidth" :max="selectedWidget.box.maxWidth" :value="selectedPosition.width" :disabled="!editAvailable || !!state.drag" @change="boxInput('width', $event)"></label>
|
||||
<label>Height<input class="gx-field" type="number" step="10" :min="selectedWidget.box.minHeight" :max="selectedWidget.box.maxHeight" :value="selectedPosition.height" :disabled="!editAvailable || !!state.drag" @change="boxInput('height', $event)"></label></template></div>
|
||||
<template v-if="selectedWidget.box">
|
||||
<label :for="'layout-box-width'">Width · {{ selectedPosition.width }}</label>
|
||||
<input id="layout-box-width" class="gx-slider" type="range" step="10" :min="selectedWidget.box.minWidth" :max="selectedWidget.box.maxWidth"
|
||||
:value="selectedPosition.width" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="boxSlider('width', $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<label :for="'layout-box-height'">Height · {{ selectedPosition.height }}</label>
|
||||
<input id="layout-box-height" class="gx-slider" type="range" step="10" :min="selectedWidget.box.minHeight" :max="selectedWidget.box.maxHeight"
|
||||
:value="selectedPosition.height" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="boxSlider('height', $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<span class="gx-note">Smaller sizes let more of the road show through.</span>
|
||||
</template>
|
||||
<span class="gx-note gx-layout__keys-note">Use arrow keys on a preview widget to move 1 pixel, or Shift + arrow for 10.</span>
|
||||
<section v-if="!projection && colorFields.length" class="gx-layout__colors" :aria-label="selectedWidget.label + ' colors'">
|
||||
<div class="gx-layout__subhead"><h4>{{ selectedWidget.label }} colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable" @click="resetColors" aria-label="Reset widget colors" title="Reset widget colors">Reset</button></div>
|
||||
<p class="gx-note">Changes this widget in the {{ state.profile === 'large' ? 'Big' : 'Small' }} layout only. Status indicators keep their meaning.</p>
|
||||
<div class="gx-layout__palette"><div v-for="field in colorFields" :key="field.id" class="gx-layout__color" :class="{'is-open': state.openColor === 'widget:' + field.id}">
|
||||
<button class="gx-layout__color-row" type="button" :aria-expanded="state.openColor === 'widget:' + field.id" :aria-controls="'layout-color-panel-' + field.id" @click="toggleColor('widget:' + field.id)">
|
||||
<span class="gx-layout__swatch" :style="{'--swatch': selectedColors[field.id]}" aria-hidden="true"></span>
|
||||
<span class="gx-layout__color-name">{{ field.label }}</span>
|
||||
<span class="gx-layout__color-value">{{ selectedColors[field.id].slice(0,7) }}</span>
|
||||
<span class="gx-layout__color-alpha">{{ Math.round(alpha(selectedColors[field.id]) / 255 * 100) }}%</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="state.openColor === 'widget:' + field.id" :id="'layout-color-panel-' + field.id" class="gx-layout__color-editor">
|
||||
<label class="gx-sr-only" :for="'layout-color-' + field.id">{{ field.label }} hex</label><div class="gx-layout__color-values">
|
||||
<input type="color" :aria-label="field.label + ' color'" :value="selectedColors[field.id].slice(0,7)" :disabled="!editAvailable || !!state.drag" @input="colorRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<input :id="'layout-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="selectedColors[field.id]" :disabled="!editAvailable || !!state.drag" @change="colorText(field.id, $event)"></div>
|
||||
<label :for="'layout-alpha-' + field.id">Opacity · {{ Math.round(alpha(selectedColors[field.id]) / 255 * 100) }}%</label>
|
||||
<input :id="'layout-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(selectedColors[field.id])" :disabled="!editAvailable || !!state.drag" @input="colorAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<p v-if="!alpha(selectedColors[field.id])" class="gx-note">Hidden at 0%. Picking a color makes it visible.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
<GxNotice tone="danger" v-if="state.colorError">{{ state.colorError }}</GxNotice>
|
||||
</section>
|
||||
<p v-if="!projection && !colorFields.length" class="gx-note">This widget uses its original status colors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="!projection" v-show="state.inspectorPanel === 'road'" class="gx-layout__colors" aria-label="Path and lane colors">
|
||||
<div class="gx-layout__subhead"><h4>Path & Lane Colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !!state.drag" @click="resetRoad" aria-label="Reset road colors" title="Reset road colors">Reset</button></div>
|
||||
<label>Path style<GalaxySelect class="gx-field gx-field--full" :value="roadMode" :disabled="!editAvailable || !!state.drag" @change="setRoadMode($event.target.value)">
|
||||
<option value="acceleration">Acceleration colors</option>
|
||||
<option value="color">Custom color</option><option value="rainbow">Rainbow Road</option>
|
||||
</GalaxySelect></label>
|
||||
<p class="gx-note">These colors apply to this layout only. The preview uses a sample road.</p>
|
||||
<details class="gx-layout__help"><summary>About path & lane colors</summary><div>
|
||||
<p class="gx-note">Choose how the path is colored. Acceleration colors change with acceleration and braking. Custom color stays one color. Rainbow Road shows a moving rainbow.</p>
|
||||
<p class="gx-note">{{ state.profile === 'large' ? 'Path border colors the strips along the path. Lane lines keep their own color.' : 'Lane marking colors apply to the lines beside your lane and the outer lines separately.' }} Blue lane-centering and orange steering-effort indicators stay blue and orange.</p>
|
||||
</div></details>
|
||||
<div class="gx-layout__palette"><div v-for="field in state.data.metadata.roadColorFields" :key="field.id" class="gx-layout__color" :class="{'is-open': state.openColor === 'road:' + field.id}">
|
||||
<button class="gx-layout__color-row" type="button" :aria-expanded="state.openColor === 'road:' + field.id" :aria-controls="'road-color-panel-' + field.id" @click="toggleColor('road:' + field.id)">
|
||||
<span class="gx-layout__swatch" :style="{'--swatch': roadColors[field.id]}" aria-hidden="true"></span>
|
||||
<span class="gx-layout__color-name">{{ roadLabel(field.id) }}</span>
|
||||
<span class="gx-layout__color-value">{{ roadColors[field.id].slice(0,7) }}</span>
|
||||
<span class="gx-layout__color-alpha">{{ Math.round(alpha(roadColors[field.id]) / 255 * 100) }}%</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="state.openColor === 'road:' + field.id" :id="'road-color-panel-' + field.id" class="gx-layout__color-editor">
|
||||
<label class="gx-sr-only" :for="'road-color-' + field.id">{{ roadLabel(field.id) }} hex</label><div class="gx-layout__color-values">
|
||||
<input type="color" :aria-label="roadLabel(field.id) + ' color'" :value="roadColors[field.id].slice(0,7)" :disabled="!editAvailable || !!state.drag" @input="roadRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<input :id="'road-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="roadColors[field.id]" :disabled="!editAvailable || !!state.drag" @change="roadColor(field.id, $event.target.value.trim())"></div>
|
||||
<label :for="'road-alpha-' + field.id">Opacity · {{ Math.round(alpha(roadColors[field.id]) / 255 * 100) }}%</label>
|
||||
<input :id="'road-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(roadColors[field.id])" :disabled="!editAvailable || !!state.drag" @input="roadAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
</div>
|
||||
</div></div>
|
||||
<GxNotice tone="danger" v-if="state.colorError">{{ state.colorError }}</GxNotice>
|
||||
</section>
|
||||
<section v-if="projection" v-show="state.inspectorPanel === 'display'" class="gx-layout__colors gx-layout__display" aria-label="Android Auto screen options">
|
||||
<div class="gx-layout__subhead"><h4>Android Auto screen options</h4></div>
|
||||
<p class="gx-note">Only the Android Auto screen uses these. The comma screen has its own options on the Comma tab. Save to apply.</p>
|
||||
<h5 class="gx-layout__display-group">Alerts and borders</h5>
|
||||
<GalaxySettingRow :row="switchRow('Hide Personality Alerts', state.draft.hidePersonalityAlerts === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('hidePersonalityAlerts', value)" />
|
||||
<GalaxySettingRow :row="switchRow('Red Blind-Spot Border', state.draft.blindSpotBorder === true)" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('blindSpotBorder', value)" />
|
||||
<GalaxySettingRow :row="switchRow('Amber Signal Border', state.draft.signalBorder === true)" :index="2"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('signalBorder', value)" />
|
||||
<h5 class="gx-layout__display-group">Lead car</h5>
|
||||
<GalaxySettingRow :row="switchRow('Lead Indicator', state.draft.leadIndicator === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('leadIndicator', value)" />
|
||||
<GalaxySettingRow :row="projectionLeadInfoRow()" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || state.draft.leadIndicator !== true" :save-value="(index, value) => setProjectionLeadInfo(value)" />
|
||||
<h5 class="gx-layout__display-group">Camera</h5>
|
||||
<GalaxySettingRow :row="switchRow('Show Camera on Blind-Spot Warning', state.draft.blindSpotCamera === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || !projectionHasPipWidget" :save-value="(index, value) => setProjectionDisplay('blindSpotCamera', value)" />
|
||||
<p v-if="!projectionHasPipWidget" class="gx-note">Add a left or right blind-spot camera widget to enable this option.</p>
|
||||
<GalaxySettingRow :row="switchRow('Large UI Gamma Effect', state.draft.largeUiGammaTrial)" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setLargeUiGammaTrial(value)" />
|
||||
<p class="gx-note">The large UI road-camera color effect on Android Auto (not shown in the preview).</p>
|
||||
</section>
|
||||
<section v-if="!projection && displayGroups.length" v-show="state.inspectorPanel === 'display'" class="gx-layout__colors gx-layout__display" aria-label="Comma screen options">
|
||||
<div class="gx-layout__subhead"><h4>Comma screen options</h4></div>
|
||||
<p class="gx-note">Only the comma screen uses these, in both the Big and Small layouts unless marked. Android Auto has its own options on the Android Auto tab. Save to apply.</p>
|
||||
<template v-for="group in displayGroups" :key="group.name">
|
||||
<h5 class="gx-layout__display-group">{{ group.name }}</h5>
|
||||
<GalaxySettingRow v-for="(row, index) in group.rows" :key="row.key" :row="displaySettingRow(row)" :index="index"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || !displayAvailable(row)" :save-value="(_, value) => setDisplay(row.key, value)" />
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<GxDialog v-if="state.removeConfirm && profile" labelledby="gx-layout-remove-title" describedby="gx-layout-remove-body" alert @close="cancelRemove">
|
||||
<div><h3 id="gx-layout-remove-title">Remove {{ profile.widgets[state.removeConfirm]?.label }}?</h3>
|
||||
<p id="gx-layout-remove-body">Remove this widget from the {{ projection ? 'Android Auto' : state.profile === 'large' ? 'Big' : 'Small' }} layout? You can add it again from Inactive Widgets.</p></div>
|
||||
<div class="gx-settings__controls gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="cancelRemove">Cancel</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="confirmRemove">Remove widget</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
<GxDialog v-if="modalLeavePrompt" class="gx-layout__leave-dialog" :inert="busy" labelledby="gx-layout-leave-title" describedby="gx-layout-leave-body" alert @close="cancelLeave">
|
||||
<div class="gx-layout__leave-copy"><h3 id="gx-layout-leave-title">Leave without saving your changes?</h3>
|
||||
<p id="gx-layout-leave-body">Save your {{ projection ? 'Android Auto' : 'comma' }} layout before leaving, keep editing, or discard this draft.</p></div>
|
||||
<GxNotice v-if="state.error" tone="danger">{{ state.error }}</GxNotice>
|
||||
<div class="gx-layout__leave-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="cancelLeave">Keep editing</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="leave(state.discard)">Discard and leave</button>
|
||||
<button type="button" class="gx-btn" :disabled="!saveAvailable" @click="saveAndLeave">{{ state.status === 'saving' ? 'Saving…' : 'Save changes' }}</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
</section>`
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ONROAD_LAYOUT_TEMPLATE } from "./onroad-layout-template.js"
|
||||
import { GxState } from "./state.js"
|
||||
import { guardUnload } from "./unload-guard.js"
|
||||
import { PollTimer, connectionError } from "./polling.js"
|
||||
@@ -1229,396 +1230,5 @@ export const OnroadLayoutPage = {
|
||||
return saved
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<section class="gx-settings gx-layout" aria-label="Colors and layout" :aria-busy="busy || undefined">
|
||||
<div class="gx-settings__header gx-page-header"><div><h2>{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}</h2></div>
|
||||
<button class="gx-icon-btn gx-layout__info" type="button" :aria-expanded="state.aboutOpen" aria-controls="gx-layout-about" aria-label="About this editor" title="About this editor" @click="state.aboutOpen = !state.aboutOpen"><i class="bi bi-info-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<GxNotice v-if="mode === 'local' && state.data && !state.data.editable && !(projection && state.data.reason)" tone="warn">Park the vehicle and reload to edit. If it stays unavailable, reload saved settings.</GxNotice>
|
||||
<div class="gx-layout__targets">
|
||||
<nav class="gx-layout__tabs gx-layout__segment" aria-label="Layout target">
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="!projection" :disabled="!!state.drag" @click="$emit('target', 'device')">Comma</button>
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="projection" :disabled="!!state.drag" @click="$emit('target', 'projection')">Android Auto</button>
|
||||
</nav>
|
||||
<div v-if="mode === 'local' && state.data && state.draft && !projection && availableProfiles.length" class="gx-layout__tabs gx-layout__segment" role="group" aria-label="Layout size">
|
||||
<button v-for="tab in [{id:'large',label:'Big'}, {id:'compact',label:'Small'}].filter(tab => availableProfiles.includes(tab.id))" :key="tab.id" class="gx-btn gx-btn--tonal" type="button"
|
||||
:aria-pressed="state.profile === tab.id" :disabled="!!state.drag" @click="selectProfile(tab.id)">{{ tab.label }}<span v-if="state.data.activeProfile === tab.id" class="gx-layout__active" title="Active UI on the comma"><span class="gx-sr-only"> (active UI)</span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="state.aboutOpen" id="gx-layout-about" class="gx-layout__about">
|
||||
<p v-if="!projection" class="gx-note">Choose a widget to move it or change its colors. Edit the layout available on this comma.</p><p v-else class="gx-note">Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.</p>
|
||||
<p v-if="projection && state.data?.screen" class="gx-note">Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels.</p>
|
||||
</div>
|
||||
<p v-if="projection && state.data?.reason" class="gx-note">{{ state.data.reason }}</p>
|
||||
<GxState v-if="mode !== 'local'">Connect to local Galaxy to edit this device’s colors and layouts.</GxState>
|
||||
<template v-else>
|
||||
<div ref="leaveBar" class="gx-layout__savebar" :class="{'gx-layout__savebar--leave': inlineLeavePrompt, 'is-stuck': state.stuck.leaveBar}" :inert="busy">
|
||||
<div v-if="snapInBar && !inlineLeavePrompt" class="gx-layout__savebar-snap" aria-hidden="true"><i class="bi bi-magnet" aria-hidden="true"></i>{{ snapSummary }}</div>
|
||||
<span role="status" :class="{'is-dirty': dirty && !inlineLeavePrompt && state.status !== 'saving'}">{{ inlineLeavePrompt ? (pendingLeave ? 'Leave without saving your changes?' : 'Discard your edits and reload the saved colors and layouts?') : state.status === 'saving' ? 'Saving…' : state.status === 'loading' ? 'Loading…' : !state.data ? 'No saved layout loaded' : dirty ? 'Unsaved' : state.notice || 'Saved' }}</span>
|
||||
<template v-if="inlineLeavePrompt">
|
||||
<button class="gx-btn gx-btn--tonal" type="button" aria-label="Keep editing" title="Keep editing" @click="cancelLeave"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12a8 8 0 1 0 3-6M4 4v6h6"/></svg><span class="gx-layout__action-label">Keep editing</span></button>
|
||||
<button class="gx-btn gx-btn--danger" type="button" :aria-label="pendingLeave ? 'Discard and leave' : 'Discard and reload'" :title="pendingLeave ? 'Discard and leave' : 'Discard and reload'" @click="leave(state.discard)"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13"/></svg><span class="gx-layout__action-label">{{ pendingLeave ? 'Discard and leave' : 'Discard and reload' }}</span></button>
|
||||
<button v-if="pendingLeave" class="gx-btn" type="button" :disabled="!saveAvailable" aria-label="Save changes" title="Save changes" @click="saveAndLeave"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg><span class="gx-layout__action-label">{{ state.status === 'saving' ? 'Saving…' : 'Save changes' }}</span></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !canUndo || !!state.drag || !!state.layerDrag" aria-label="Undo" title="Undo" @click="undo"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5 4 10l5 5M4 10h10a6 6 0 0 1 0 12"/></svg><span class="gx-layout__action-label">Undo</span></button>
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !canRedo || !!state.drag || !!state.layerDrag" aria-label="Redo" title="Redo" @click="redo"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 5 5 5-5 5m5-5H10a6 6 0 0 0 0 12"/></svg><span class="gx-layout__action-label">Redo</span></button>
|
||||
<div ref="moreMenu" class="gx-layout__more" @keydown.esc="closeFloating('moreOpen')">
|
||||
<button class="gx-btn gx-btn--tonal" type="button" :aria-expanded="state.moreOpen" aria-haspopup="menu" aria-label="More actions" title="More actions" @click="toggleFloating('moreOpen', 'moreMenu')"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg><span class="gx-layout__action-label">More</span></button>
|
||||
<template v-if="state.moreOpen">
|
||||
<div class="gx-layout__menu" role="menu" aria-label="More actions">
|
||||
<button class="gx-layout__menu-item" type="button" role="menuitem" :disabled="!!state.drag || !!state.layerDrag" @click="closeFloating('moreOpen'); requestLeave('reload')"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 7v5h-5M20 12a8 8 0 1 0-2 5"/></svg>Reload saved</button>
|
||||
<button class="gx-layout__menu-item" type="button" role="menuitem" :disabled="!editAvailable || !stockChanged || !!state.drag || !!state.layerDrag" @click="closeFloating('moreOpen'); resetToStock()"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 11 9-8 9 8M5 10v11h14V10M9 21v-7h6v7"/></svg>Reset to stock StarPilot</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="gx-btn gx-layout__save" type="button" :disabled="!editAvailable || !dirty || !!state.drag || !!state.layerDrag" aria-label="Save changes" title="Save changes" @click="save"><svg class="gx-layout__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg><span class="gx-layout__action-label gx-layout__save-label">Save<span class="gx-layout__long"> changes</span></span></button>
|
||||
</template>
|
||||
</div>
|
||||
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
|
||||
<template v-if="state.data && state.draft">
|
||||
<p v-if="!state.data.valid" class="gx-note" role="status">{{ projection ? 'Default Android Auto positions are shown. Save to keep your separate layout.' : 'The saved customization is invalid. Default colors and positions are shown; save your edits to replace it.' }}</p>
|
||||
<div class="gx-layout__workspace" :inert="busy">
|
||||
<section ref="pinned" class="gx-card gx-layout__preview-card" :class="{'is-stuck': state.stuck.pinned}" aria-label="Driving screen preview" :style="{'--layout-ratio': profile.width / profile.height}">
|
||||
<div class="gx-layout__subhead"><strong>Layout preview</strong><span>{{ state.layerDrag ? "Adjusting " + selectedWidget.label : state.art.status === 'updating' || state.backdrop.status === 'updating' ? 'Updating preview…' : profile.width + " × " + profile.height }}</span></div>
|
||||
<div class="gx-layout__preview-bar">
|
||||
<GalaxySelect class="gx-field gx-field--full" aria-label="Preview scene" :value="state.scene" :disabled="!!state.drag || !!state.layerDrag" @change="state.scene = $event.target.value">
|
||||
<optgroup v-for="group in sceneGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="scene in group.scenes" :key="scene.id" :value="scene.id">{{ scene.label }}</option>
|
||||
</optgroup>
|
||||
</GalaxySelect>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.snapEnabled" :disabled="!!state.drag || !!state.layerDrag" aria-label="Snap to edges and centers" title="Snap to edges and centers" @click="toggleSnap"><i class="bi bi-magnet" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showEditingAids" aria-label="Show editing aids" title="Show editing aids" @click="toggleEditingAids"><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
|
||||
<button v-if="!projection && profile.inputZones?.length" class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showFavoriteZones" aria-label="Show Favorite tap areas" title="Show Favorite tap areas" @click="state.showFavoriteZones = !state.showFavoriteZones"><i class="bi bi-grid-3x3-gap" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-expanded="state.tipsOpen" aria-controls="gx-layout-tips" aria-label="Preview tips and display behavior" title="Preview tips and display behavior" @click="state.tipsOpen = !state.tipsOpen"><i class="bi bi-question-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<p class="gx-note">{{ state.scene === 'general' ? 'Arrange all enabled widgets with sample readings. Use a driving scene to check when they appear.' : 'Eyes follow this driving scene. Choose General edit to arrange widgets hidden here.' }}</p>
|
||||
<p v-if="state.backdrop.status === 'unavailable'" class="gx-note" role="status">{{ nativeBackdropReady ? 'The comma screen could not refresh. Showing the last one.' : 'The comma screen is unavailable. Showing a sketch.' }}</p>
|
||||
<p v-if="state.art.status === 'unavailable'" class="gx-note" role="status">{{ state.art.url ? 'Widget preview could not refresh. Showing the last preview.' : 'Widget preview unavailable. Placement controls remain available.' }}</p>
|
||||
<div class="gx-layout__stage">
|
||||
<span v-if="state.showEditingAids && state.snapGuides.length && !snapInBar" class="gx-layout__stage-chip gx-layout__stage-chip--start" :class="{'gx-layout__stage-chip--with-tap-areas': !projection && state.showFavoriteZones && state.profile !== 'compact'}" aria-hidden="true">{{ snapSummary }}</span>
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile !== 'compact'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<svg ref="preview" class="gx-layout__preview" :style="{'--layout-glow': state.profile === 'compact' ? '4px' : '14px'}" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
|
||||
@pointermove="moveDrag" @pointerup="endDrag" @pointercancel="cancelDrag" @lostpointercapture="cancelDrag"
|
||||
role="group" :aria-label="projection ? 'Android Auto layout preview' : (state.profile === 'large' ? 'Big' : 'Small') + ' layout preview'">
|
||||
<image v-if="nativeBackdropReady" :href="state.backdrop.url" :width="profile.width" :height="profile.height" preserveAspectRatio="none" data-preview-source="device-backdrop" />
|
||||
<template v-else>
|
||||
<rect width="100%" height="100%" fill="#252b32" />
|
||||
<polygon :points="roadPoints([[0,1],[.4,.45],[.55,.45],[.83,1]])" fill="#303943" />
|
||||
<defs><linearGradient id="layout-road-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
|
||||
<stop v-for="stop in roadGradient" :key="stop.offset" :offset="stop.offset" :stop-color="stop.color" :stop-opacity="stop.alpha" />
|
||||
</linearGradient></defs>
|
||||
<defs><linearGradient id="layout-edge-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
|
||||
<stop offset="0" :stop-color="roadColors.pathEdge.slice(0,7)" stop-opacity="0" />
|
||||
<stop offset=".5" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .35) / 255" />
|
||||
<stop offset="1" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .4) / 255" />
|
||||
</linearGradient></defs>
|
||||
<polygon v-if="state.profile === 'large' && roadStyle.pathEdge" v-for="strip in [[[.18,1],[.44,.45],[.447,.45],[.231,1]], [[.639,1],[.503,.45],[.51,.45],[.69,1]]]"
|
||||
:points="roadPoints(strip)" fill="url(#layout-edge-gradient)" />
|
||||
<polygon :points="roadPoints(state.profile === 'large' && roadStyle.pathEdge ? [[.231,1],[.447,.45],[.503,.45],[.639,1]] : [[.18,1],[.44,.45],[.51,.45],[.69,1]])" fill="url(#layout-road-gradient)" />
|
||||
<path v-for="(line,index) in [[[.16,1],[.43,.45]],[[.71,1],[.52,.45]],[[.02,1],[.38,.45]],[[.85,1],[.57,.45]]]" :key="'road-line-'+index"
|
||||
:d="'M'+roadPoints(line).replace(' ', ' L')"
|
||||
:stroke="state.profile === 'compact' && index < 2 ? (roadStyle.pathEdge || '#00ff40ff') : roadColors.laneLines"
|
||||
opacity=".7" :stroke-width="state.profile === 'compact' ? 2 : 5" />
|
||||
<rect v-if="!projection && !expandedRoad" :x="state.profile === 'large' ? 1860 : 476" y="0" :width="state.profile === 'large' ? 300 : 60" :height="profile.height" fill="#090c10" />
|
||||
<g v-if="!projection && state.profile === 'large' && !expandedRoad" aria-hidden="true" pointer-events="none">
|
||||
<g v-for="(sample, index) in [{label:'ACCEL',value:'1.08 ft/s²',accent:'#ffffff'}, {label:'STEER DELAY',value:'0.15000',accent:'#3b82f6'}, {label:'FRICTION',value:'0.12000',accent:'#22c55e'}, {label:'LAT ACCEL',value:'2.50000',accent:'#3b82f6'}, {label:'LATERAL %',value:'78.00%',accent:'#ffffff'}, {label:'TORQUE %',value:'42%',accent:'#ffffff'}, {label:'CHESTNUT',value:'',accent:'#ffffff'}]" :key="sample.label" :transform="'translate(1873 ' + (24 + index * 150) + ')'">
|
||||
<rect width="275" height="126" rx="16" fill="#000000" stroke="#ffffff55" stroke-width="2" />
|
||||
<rect x="253" y="4" width="18" height="118" rx="4" :fill="sample.accent" />
|
||||
<text x="137" :y="sample.value ? 51 : 75" text-anchor="middle" :font-size="sample.label === 'STEER DELAY' ? 26 : 30" font-weight="600" fill="#ffffff">{{ sample.label }}</text>
|
||||
<text v-if="sample.value" x="137" y="91" text-anchor="middle" font-size="30" font-weight="600" fill="#ffffff">{{ sample.value }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</template>
|
||||
<rect v-if="!nativeBackdropReady" data-preview-source="scene-border"
|
||||
:x="state.profile === 'compact' ? 4 : profile.bounds.x" :y="state.profile === 'compact' ? 4 : profile.bounds.y"
|
||||
:width="state.profile === 'compact' ? 468 : roadBounds.width" :height="state.profile === 'compact' ? 232 : profile.bounds.height"
|
||||
:rx="state.profile === 'compact' ? 14 : 61" fill="none" :stroke="sceneBorder" :stroke-width="state.profile === 'compact' ? 8 : 30" />
|
||||
<rect v-if="state.showEditingAids" :x="roadBounds.x" :y="roadBounds.y" :width="roadBounds.width" :height="roadBounds.height" fill="none" stroke="#8495a855" stroke-width="2" stroke-dasharray="8 8" />
|
||||
<g v-if="!projection && state.showFavoriteZones && state.showEditingAids" aria-hidden="true" pointer-events="none">
|
||||
<g v-for="zone in profile.inputZones || []" :key="zone.id">
|
||||
<rect :x="zone.x" :y="zone.y" :width="zone.width" :height="zone.height" fill="#b799ff16" stroke="#b799ff88" :stroke-width="state.profile === 'large' ? 3 : 1" stroke-dasharray="5 5" />
|
||||
<text :x="zone.x + zone.width / 2" :y="zone.y + zone.height - 8" text-anchor="middle" :font-size="state.profile === 'large' ? 20 : 10" fill="#d8c8ff">{{ zone.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="widget in renderWidgets" :key="widget.id" class="gx-layout__widget" :class="{'is-selected': state.drag?.id === widget.id, 'is-editing': state.showEditingAids && state.inspectorPanel === 'edit' && state.selected === widget.id}"
|
||||
:transform="'translate(' + previewX(widget.id) + ' ' + layout[widget.id].y + ')'" tabindex="0" role="button"
|
||||
:aria-label="widget.label + ', x ' + previewX(widget.id) + ', y ' + layout[widget.id].y +
|
||||
(widget.frontmost ? '. Arrow keys move; Shift moves ten pixels; required and always frontmost widget.' :
|
||||
widget.required ? '. Arrow keys move; Shift moves ten pixels; required widget.' : '. Arrow keys move; Shift moves ten pixels; Delete removes.')"
|
||||
:aria-pressed="state.selected === widget.id" @focus="state.selected = widget.id" @keydown="onKey(widget.id, $event)"
|
||||
@pointerdown="startDrag(widget.id, $event)">
|
||||
<rect class="gx-layout__hit" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="transparent" />
|
||||
<LayoutWidgetPreview :widget="widget" :palette="widgetColors(widget.id)" :profile="state.profile" :scene="state.scene" :clock24-hour="!!state.draft.clock24Hour" :native="runtimeWidget(widget.id)" :art="state.art" />
|
||||
<rect class="gx-layout__selection" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="none" stroke="#b799ff" :stroke-width="state.profile === 'large' ? 5 : 1.5" stroke-dasharray="6 4" />
|
||||
<g v-if="widget.box && state.selected === widget.id && editable" class="gx-layout__resize" role="button" :aria-label="'Resize ' + widget.label"
|
||||
:transform="'translate(' + widget.width + ' ' + widget.height + ')'" @pointerdown="startResize(widget.id, $event)">
|
||||
<circle r="34" fill="transparent" /><circle r="16" fill="#b799ff" stroke="#16121f" stroke-width="4" />
|
||||
<path d="M-6 6 L6 -6 M0 6 L6 0" stroke="#16121f" stroke-width="3" stroke-linecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
<g v-if="projection && !nativeBackdropReady" data-preview-source="fixed-wordmark" role="img" aria-label="StarPilot, fixed logo" pointer-events="none"
|
||||
:transform="'translate(' + (profile.width - projectionWordmark.right) + ' ' + (profile.height - projectionWordmark.bottom) + ') scale(' + projectionWordmark.scale + ')'">
|
||||
<svg v-for="(glyph, index) in projectionWordmark.glyphs" :key="index" :x="glyph.offsetX" :y="glyph.offsetY"
|
||||
:width="glyph.width" :height="glyph.height" :viewBox="[glyph.x, glyph.y, glyph.width, glyph.height].join(' ')" aria-hidden="true">
|
||||
<image href="./assets/starpilot-wordmark.png" width="1024" height="512" />
|
||||
</svg>
|
||||
</g>
|
||||
<g v-if="state.showEditingAids && state.snapGuides.length" class="gx-layout__guides" aria-hidden="true" pointer-events="none">
|
||||
<line v-for="guide in state.snapGuides" :key="guide.axis + guide.value" :class="'gx-layout__guide gx-layout__guide--' + guide.kind"
|
||||
:x1="guide.axis === 'x' ? guide.value + previewShift(state.drag?.id) : 0" :x2="guide.axis === 'x' ? guide.value + previewShift(state.drag?.id) : profile.width"
|
||||
:y1="guide.axis === 'y' ? guide.value : 0" :y2="guide.axis === 'y' ? guide.value : profile.height" vector-effect="non-scaling-stroke" />
|
||||
</g>
|
||||
<rect v-if="state.showEditingAids && selectionBox" class="gx-layout__outline" :x="selectionBox.x" :y="selectionBox.y" :width="selectionBox.width" :height="selectionBox.height" rx="4" fill="none" stroke="#d4baff" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- The Small screen is too short to carry the chip over the layout; it sits underneath instead. -->
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile === 'compact'" class="gx-layout__stage-chip gx-layout__stage-chip--below" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<div v-show="state.tipsOpen" id="gx-layout-tips" class="gx-layout__tips">
|
||||
<p class="gx-note">Drag widgets to place them. Behind them is the comma's own screen for the chosen scene, including its border, road and alerts. Readings are samples.</p>
|
||||
<p class="gx-note">Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
|
||||
<p class="gx-note">General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.</p>
|
||||
<p class="gx-note">The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.</p>
|
||||
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.</p>
|
||||
<p v-if="!projection" class="gx-note">Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.</p>
|
||||
<p v-if="!projection && state.showFavoriteZones" class="gx-note">Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.</p>
|
||||
</div>
|
||||
<p v-if="state.placementError" class="gx-note" role="status">{{ state.placementError }}</p>
|
||||
</section>
|
||||
<section class="gx-card gx-layout__inspector" aria-label="Widget placement" @focusin="revealField"> <!-- codespell:ignore focusin -->
|
||||
<nav ref="sections" class="gx-layout__switcher gx-layout__sections gx-settings-tabs" :class="{'is-stuck': state.stuck.sections}" aria-label="Editor sections">
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'widgets'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'widgets'">Widgets</button>
|
||||
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'edit'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'edit'"><span>Edit<span class="gx-layout__long"> widget</span></span></button>
|
||||
<button v-if="!projection" class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'road'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'road'"><span>Road<span class="gx-layout__long"> colors</span></span></button>
|
||||
<button v-if="projection || displayGroups.length" class="gx-btn gx-btn--tonal" :aria-pressed="state.inspectorPanel === 'display'" :disabled="!!state.drag || !!state.layerDrag" @click="state.inspectorPanel = 'display'">Options</button>
|
||||
</nav>
|
||||
<div v-show="state.inspectorPanel === 'widgets'" class="gx-layout__panel">
|
||||
<div ref="widgetsHelp" class="gx-layout__subhead gx-layout__help-anchor"><h3>Widgets</h3><span>{{ activeWidgets.length }} active</span>
|
||||
<button class="gx-icon-btn gx-layout__info" type="button" :aria-expanded="state.widgetsHelpOpen" aria-controls="gx-layout-widgets-help" aria-label="How the widget list works" title="How the widget list works" @click="toggleFloating('widgetsHelpOpen', 'widgetsHelp')"><i class="bi bi-info-circle" aria-hidden="true"></i></button>
|
||||
<div v-show="state.widgetsHelpOpen" id="gx-layout-widgets-help" class="gx-layout__popover" @click="closeFloating('widgetsHelpOpen')" @keydown.esc="closeFloating('widgetsHelpOpen')">
|
||||
<p>Tap a widget to edit it.</p><p>Drag ⠿ to reorder. The top row is in front, and alerts stay above all widgets.</p><p>The eyes follow the chosen scene. Choose General edit to arrange every enabled widget. Hiding a widget with the eye affects only this scene's preview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gx-layout__widget-list"><div v-for="widget in listWidgets" :key="widget.id" class="gx-layout__layer" :data-layer-id="widget.id"
|
||||
:class="{'is-dragging': state.layerDrag?.id === widget.id && state.layerDrag?.moved, 'is-drop-target': state.layerDrag?.target === widget.id && state.layerDrag?.id !== widget.id, 'is-drop-after': state.layerDrag?.after}">
|
||||
<button class="gx-layout__row-grip gx-icon-btn" type="button"
|
||||
:aria-label="widget.frontmost ? widget.label + ' is pinned above all widgets' : 'Reorder ' + widget.label"
|
||||
:disabled="!editAvailable || !!state.drag || widget.frontmost"
|
||||
:title="widget.frontmost ? 'Always above other widgets' : 'Drag to reorder. Up and down keys reorder.'"
|
||||
@pointerdown="startLayerDrag($event, widget.id)" @pointermove="moveLayerDrag($event)"
|
||||
@pointerup="endLayerDrag($event)" @pointercancel="endLayerDrag($event, true)" @lostpointercapture="endLayerDrag($event, true)"
|
||||
@keydown="layerKey($event, widget.id)" @click="layerClick($event, widget.id)"><span aria-hidden="true">{{ widget.frontmost ? '↑' : '⠿' }}</span></button>
|
||||
<button class="gx-layout__layer-name" type="button" :aria-pressed="state.selected === widget.id" :disabled="!!state.drag || !!state.layerDrag"
|
||||
@click="selectWidget(widget.id)">{{ widget.label }}</button>
|
||||
<button v-if="!projection || widget.id !== 'car_exit'" class="gx-layout__visibility gx-icon-btn" type="button" :disabled="!!state.drag || !!state.layerDrag || sceneHidden(widget.id)"
|
||||
:aria-label="visibilityTitle(widget.id, widget.label)"
|
||||
:title="visibilityTitle(widget.id, widget.label)"
|
||||
:aria-pressed="!previewHidden(widget.id)" @click="togglePreviewVisibility(widget.id)">
|
||||
<i class="bi" :class="previewHidden(widget.id) ? 'bi-eye-slash' : 'bi-eye'" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button v-if="!widget.required" class="gx-layout__remove gx-icon-btn" type="button" :aria-label="'Remove ' + widget.label" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="requestRemove(widget.id)"><span aria-hidden="true">−</span></button>
|
||||
<span v-else class="gx-note gx-layout__required" title="This control cannot be removed or placed below another widget.">Required</span>
|
||||
</div></div>
|
||||
<Teleport to="body"><div v-if="state.layerDrag?.moved" class="gx-layout__drag-ghost" aria-hidden="true"
|
||||
:style="{left: state.layerDrag.x + 'px', top: state.layerDrag.y + 'px', width: state.layerDrag.width + 'px'}">
|
||||
<span aria-hidden="true">⠿</span><strong>{{ selectedWidget.label }}</strong><span>↕</span>
|
||||
</div></Teleport>
|
||||
<div class="gx-layout__tray" role="group" aria-labelledby="gx-layout-tray-title">
|
||||
<div class="gx-layout__subhead"><h4 id="gx-layout-tray-title">Inactive widgets</h4><span>{{ inactiveWidgets.length }}</span></div>
|
||||
<p v-if="inactiveWidgets.length" class="gx-note">Drag a widget onto the preview, or tap + to add it.</p>
|
||||
<div v-if="inactiveWidgets.length" class="gx-layout__chips">
|
||||
<div v-for="widget in inactiveWidgets" :key="widget.id" class="gx-layout__tray-item">
|
||||
<button class="gx-layout__drag-handle" type="button" :disabled="!editAvailable" :aria-label="'Drag ' + widget.label + ' onto preview'"
|
||||
@pointerdown="startDrag(widget.id, $event, true)"><i class="bi bi-grip-vertical" aria-hidden="true"></i></button><span class="gx-layout__inactive-name">{{ widget.label }}</span>
|
||||
<button class="gx-icon-btn gx-layout__chip-add" type="button" :aria-label="'Add ' + widget.label" :title="'Add ' + widget.label" :disabled="!editAvailable || !!state.drag" @click="add(widget.id)"><i class="bi bi-plus-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="gx-note">All available widgets are on this layout.</p>
|
||||
</div>
|
||||
<button class="gx-btn gx-btn--tonal gx-layout__reset" type="button" :disabled="!editAvailable || !!state.drag" @click="resetLayout">Reset this layout</button>
|
||||
</div>
|
||||
<div v-show="state.inspectorPanel === 'edit'" class="gx-layout__panel">
|
||||
<label class="gx-layout__widget-picker"><span class="gx-layout__picker-head">Selected widget<span v-if="selectedWidget && selectedPosition.enabled" class="gx-note">{{ selectedSize[0] }} × {{ selectedSize[1] + (selectedWidget.box ? 0 : selectedWidget.visualInsetTop || 0) }} px</span></span><GalaxySelect class="gx-field gx-field--full" :value="state.selected" :disabled="!!state.drag" @change="state.selected = $event.target.value">
|
||||
<option v-for="widget in widgets" :key="widget.id" :value="widget.id">{{ widget.label }}{{ layout[widget.id].enabled ? '' : ' · Inactive' }}</option>
|
||||
</GalaxySelect></label>
|
||||
<div v-if="selectedWidget && !selectedPosition.enabled" class="gx-layout__panel"><p class="gx-note">Add this widget to edit its position and colors.</p><button class="gx-btn" :disabled="!editAvailable" @click="add(state.selected)">Add to layout</button></div>
|
||||
<p v-if="selectedWidget && selectedPosition.enabled && sceneHidden(state.selected)" class="gx-note">This widget is hidden in this driving scene. <button class="gx-btn gx-btn--tonal" type="button" :disabled="!!state.drag || !!state.layerDrag" @click="state.scene = 'general'">Edit in General edit</button></p>
|
||||
<div v-if="selectedWidget && selectedPosition.enabled" class="gx-layout__position">
|
||||
<label v-if="selectedWidget.resizable">Size
|
||||
<input class="gx-field" type="number" step="1" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
|
||||
:value="selectedPosition.size" :disabled="!editAvailable || !!state.drag" @change="resizeSelected($event)"></label>
|
||||
<input v-if="selectedWidget.resizable" class="gx-slider" type="range" step="1" aria-label="Size" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
|
||||
:value="selectedPosition.size" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="sizeSlider($event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<template v-if="selectedWidget.opacity">
|
||||
<label for="layout-widget-opacity">{{ selectedWidget.box ? 'Map opacity' : selectedWidget.kind === 'cruise_limits' ? 'Background opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
|
||||
<input id="layout-widget-opacity" class="gx-slider" type="range" step="1" :min="selectedWidget.opacity.min" :max="selectedWidget.opacity.max"
|
||||
:value="selectedPosition.opacity" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="opacityInput($event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
</template>
|
||||
<GalaxySettingRow v-if="selectedWidget.backgroundToggle" :row="switchRow('Show background', selectedPosition.background === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setWidgetBackground(value)" />
|
||||
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
|
||||
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
|
||||
<label v-if="selectedWidget.kind === 'clock'" class="gx-layout__clock-format"><input type="checkbox" :checked="!!state.draft.clock24Hour" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setClock24Hour"> Use 24-hour time</label>
|
||||
<div v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__favorite-display">
|
||||
<span>Text alignment</span>
|
||||
<div class="gx-segment" role="group" aria-label="Cruise and speed limit text alignment">
|
||||
<button v-for="align in ['left', 'center', 'right']" :key="align" class="gx-btn" type="button"
|
||||
:class="(state.draft.speedAlign || 'left') === align ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="(state.draft.speedAlign || 'left') === align" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="setSpeedAlign(align)">{{ align === 'left' ? 'Left' : align === 'center' ? 'Center' : 'Right' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits'" :row="switchRow('Show Speed-Limit Sources', state.draft.speedSources === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSources', value === 'On')" />
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits' && state.draft.speedSources === true" :row="switchRow('Show as Separate Card', state.draft.speedSourcesCard === true)" :index="1"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSourcesCard', value === 'On')" />
|
||||
<label v-if="!projection && selectedWidget.kind === 'cruise_limits'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.showMaxSpeed !== false" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setShowMaxSpeed"> Show MAX speed</label>
|
||||
<GalaxySettingRow v-if="projection && selectedWidget.kind === 'steering_wheel'" :row="switchRow('Brake and Acceleration Colors', state.draft.wheelPedalFeedback === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('wheelPedalFeedback', value)" />
|
||||
<GalaxySettingRow v-if="!projection && selectedWidget.kind === 'steering_wheel' && displayRow('ShowBrakeStatus')" :row="switchRow('Brake and Acceleration Colors', displayValue('ShowBrakeStatus') === 'On')" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('ShowBrakeStatus'))" :save-value="(index, value) => setDisplay('ShowBrakeStatus', value)" />
|
||||
<div v-if="!projection && selectedWidget.kind === 'model_confidence' && displayRow('StockConfidenceBallWidget')" class="gx-layout__favorite-display">
|
||||
<span>Style</span>
|
||||
<div class="gx-segment" role="group" aria-label="Model confidence style">
|
||||
<button v-for="choice in [['On', 'StarPilot widgets'], ['Off', 'Stock confidence ball']]" :key="choice[0]" class="gx-btn" type="button"
|
||||
:class="displayValue('StockConfidenceBallWidget') === choice[0] ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="displayValue('StockConfidenceBallWidget') === choice[0]"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('StockConfidenceBallWidget'))"
|
||||
@click="setDisplay('StockConfidenceBallWidget', choice[0])">{{ choice[1] }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!projection && stockConfidence && selectedWidget.kind === 'conditional_mode'" class="gx-note">Hidden while Model confidence uses the stock confidence ball.</p>
|
||||
<div v-if="projection && selectedWidget.iconSize" class="gx-layout__favorite-display">
|
||||
<span>Show as</span>
|
||||
<div class="gx-segment" role="group" :aria-label="selectedWidget.label + ' display'">
|
||||
<button v-for="display in ['icons', 'words']" :key="display" class="gx-btn" type="button"
|
||||
:class="selectedPosition.display === display ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="selectedPosition.display === display" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="setFavoriteDisplay(display)">{{ display === 'icons' ? 'Icons' : 'Words' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gx-layout__coordinates"><label>X<input class="gx-field" type="number" step="1" :min="limits.minX" :max="limits.maxX" :value="selectedPosition.x" :disabled="!editAvailable || !!state.drag" @change="positionInput('x', $event)"></label>
|
||||
<label>Y<input class="gx-field" type="number" step="1" :min="limits.minY" :max="limits.maxY" :value="selectedPosition.y" :disabled="!editAvailable || !!state.drag" @change="positionInput('y', $event)"></label><template v-if="selectedWidget.box">
|
||||
<label>Width<input class="gx-field" type="number" step="10" :min="selectedWidget.box.minWidth" :max="selectedWidget.box.maxWidth" :value="selectedPosition.width" :disabled="!editAvailable || !!state.drag" @change="boxInput('width', $event)"></label>
|
||||
<label>Height<input class="gx-field" type="number" step="10" :min="selectedWidget.box.minHeight" :max="selectedWidget.box.maxHeight" :value="selectedPosition.height" :disabled="!editAvailable || !!state.drag" @change="boxInput('height', $event)"></label></template></div>
|
||||
<template v-if="selectedWidget.box">
|
||||
<label :for="'layout-box-width'">Width · {{ selectedPosition.width }}</label>
|
||||
<input id="layout-box-width" class="gx-slider" type="range" step="10" :min="selectedWidget.box.minWidth" :max="selectedWidget.box.maxWidth"
|
||||
:value="selectedPosition.width" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="boxSlider('width', $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<label :for="'layout-box-height'">Height · {{ selectedPosition.height }}</label>
|
||||
<input id="layout-box-height" class="gx-slider" type="range" step="10" :min="selectedWidget.box.minHeight" :max="selectedWidget.box.maxHeight"
|
||||
:value="selectedPosition.height" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="boxSlider('height', $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<span class="gx-note">Smaller sizes let more of the road show through.</span>
|
||||
</template>
|
||||
<span class="gx-note gx-layout__keys-note">Use arrow keys on a preview widget to move 1 pixel, or Shift + arrow for 10.</span>
|
||||
<section v-if="!projection && colorFields.length" class="gx-layout__colors" :aria-label="selectedWidget.label + ' colors'">
|
||||
<div class="gx-layout__subhead"><h4>{{ selectedWidget.label }} colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable" @click="resetColors" aria-label="Reset widget colors" title="Reset widget colors">Reset</button></div>
|
||||
<p class="gx-note">Changes this widget in the {{ state.profile === 'large' ? 'Big' : 'Small' }} layout only. Status indicators keep their meaning.</p>
|
||||
<div class="gx-layout__palette"><div v-for="field in colorFields" :key="field.id" class="gx-layout__color" :class="{'is-open': state.openColor === 'widget:' + field.id}">
|
||||
<button class="gx-layout__color-row" type="button" :aria-expanded="state.openColor === 'widget:' + field.id" :aria-controls="'layout-color-panel-' + field.id" @click="toggleColor('widget:' + field.id)">
|
||||
<span class="gx-layout__swatch" :style="{'--swatch': selectedColors[field.id]}" aria-hidden="true"></span>
|
||||
<span class="gx-layout__color-name">{{ field.label }}</span>
|
||||
<span class="gx-layout__color-value">{{ selectedColors[field.id].slice(0,7) }}</span>
|
||||
<span class="gx-layout__color-alpha">{{ Math.round(alpha(selectedColors[field.id]) / 255 * 100) }}%</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="state.openColor === 'widget:' + field.id" :id="'layout-color-panel-' + field.id" class="gx-layout__color-editor">
|
||||
<label class="gx-sr-only" :for="'layout-color-' + field.id">{{ field.label }} hex</label><div class="gx-layout__color-values">
|
||||
<input type="color" :aria-label="field.label + ' color'" :value="selectedColors[field.id].slice(0,7)" :disabled="!editAvailable || !!state.drag" @input="colorRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<input :id="'layout-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="selectedColors[field.id]" :disabled="!editAvailable || !!state.drag" @change="colorText(field.id, $event)"></div>
|
||||
<label :for="'layout-alpha-' + field.id">Opacity · {{ Math.round(alpha(selectedColors[field.id]) / 255 * 100) }}%</label>
|
||||
<input :id="'layout-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(selectedColors[field.id])" :disabled="!editAvailable || !!state.drag" @input="colorAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<p v-if="!alpha(selectedColors[field.id])" class="gx-note">Hidden at 0%. Picking a color makes it visible.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
<GxNotice tone="danger" v-if="state.colorError">{{ state.colorError }}</GxNotice>
|
||||
</section>
|
||||
<p v-if="!projection && !colorFields.length" class="gx-note">This widget uses its original status colors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="!projection" v-show="state.inspectorPanel === 'road'" class="gx-layout__colors" aria-label="Path and lane colors">
|
||||
<div class="gx-layout__subhead"><h4>Path & Lane Colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editAvailable || !!state.drag" @click="resetRoad" aria-label="Reset road colors" title="Reset road colors">Reset</button></div>
|
||||
<label>Path style<GalaxySelect class="gx-field gx-field--full" :value="roadMode" :disabled="!editAvailable || !!state.drag" @change="setRoadMode($event.target.value)">
|
||||
<option value="acceleration">Acceleration colors</option>
|
||||
<option value="color">Custom color</option><option value="rainbow">Rainbow Road</option>
|
||||
</GalaxySelect></label>
|
||||
<p class="gx-note">These colors apply to this layout only. The preview uses a sample road.</p>
|
||||
<details class="gx-layout__help"><summary>About path & lane colors</summary><div>
|
||||
<p class="gx-note">Choose how the path is colored. Acceleration colors change with acceleration and braking. Custom color stays one color. Rainbow Road shows a moving rainbow.</p>
|
||||
<p class="gx-note">{{ state.profile === 'large' ? 'Path border colors the strips along the path. Lane lines keep their own color.' : 'Lane marking colors apply to the lines beside your lane and the outer lines separately.' }} Blue lane-centering and orange steering-effort indicators stay blue and orange.</p>
|
||||
</div></details>
|
||||
<div class="gx-layout__palette"><div v-for="field in state.data.metadata.roadColorFields" :key="field.id" class="gx-layout__color" :class="{'is-open': state.openColor === 'road:' + field.id}">
|
||||
<button class="gx-layout__color-row" type="button" :aria-expanded="state.openColor === 'road:' + field.id" :aria-controls="'road-color-panel-' + field.id" @click="toggleColor('road:' + field.id)">
|
||||
<span class="gx-layout__swatch" :style="{'--swatch': roadColors[field.id]}" aria-hidden="true"></span>
|
||||
<span class="gx-layout__color-name">{{ roadLabel(field.id) }}</span>
|
||||
<span class="gx-layout__color-value">{{ roadColors[field.id].slice(0,7) }}</span>
|
||||
<span class="gx-layout__color-alpha">{{ Math.round(alpha(roadColors[field.id]) / 255 * 100) }}%</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="state.openColor === 'road:' + field.id" :id="'road-color-panel-' + field.id" class="gx-layout__color-editor">
|
||||
<label class="gx-sr-only" :for="'road-color-' + field.id">{{ roadLabel(field.id) }} hex</label><div class="gx-layout__color-values">
|
||||
<input type="color" :aria-label="roadLabel(field.id) + ' color'" :value="roadColors[field.id].slice(0,7)" :disabled="!editAvailable || !!state.drag" @input="roadRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
<input :id="'road-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="roadColors[field.id]" :disabled="!editAvailable || !!state.drag" @change="roadColor(field.id, $event.target.value.trim())"></div>
|
||||
<label :for="'road-alpha-' + field.id">Opacity · {{ Math.round(alpha(roadColors[field.id]) / 255 * 100) }}%</label>
|
||||
<input :id="'road-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(roadColors[field.id])" :disabled="!editAvailable || !!state.drag" @input="roadAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
</div>
|
||||
</div></div>
|
||||
<GxNotice tone="danger" v-if="state.colorError">{{ state.colorError }}</GxNotice>
|
||||
</section>
|
||||
<section v-if="projection" v-show="state.inspectorPanel === 'display'" class="gx-layout__colors gx-layout__display" aria-label="Android Auto screen options">
|
||||
<div class="gx-layout__subhead"><h4>Android Auto screen options</h4></div>
|
||||
<p class="gx-note">Only the Android Auto screen uses these. The comma screen has its own options on the Comma tab. Save to apply.</p>
|
||||
<h5 class="gx-layout__display-group">Alerts and borders</h5>
|
||||
<GalaxySettingRow :row="switchRow('Hide Personality Alerts', state.draft.hidePersonalityAlerts === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('hidePersonalityAlerts', value)" />
|
||||
<GalaxySettingRow :row="switchRow('Red Blind-Spot Border', state.draft.blindSpotBorder === true)" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('blindSpotBorder', value)" />
|
||||
<GalaxySettingRow :row="switchRow('Amber Signal Border', state.draft.signalBorder === true)" :index="2"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('signalBorder', value)" />
|
||||
<h5 class="gx-layout__display-group">Lead car</h5>
|
||||
<GalaxySettingRow :row="switchRow('Lead Indicator', state.draft.leadIndicator === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('leadIndicator', value)" />
|
||||
<GalaxySettingRow :row="projectionLeadInfoRow()" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || state.draft.leadIndicator !== true" :save-value="(index, value) => setProjectionLeadInfo(value)" />
|
||||
<h5 class="gx-layout__display-group">Camera</h5>
|
||||
<GalaxySettingRow :row="switchRow('Show Camera on Blind-Spot Warning', state.draft.blindSpotCamera === true)" :index="0"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || !projectionHasPipWidget" :save-value="(index, value) => setProjectionDisplay('blindSpotCamera', value)" />
|
||||
<p v-if="!projectionHasPipWidget" class="gx-note">Add a left or right blind-spot camera widget to enable this option.</p>
|
||||
<GalaxySettingRow :row="switchRow('Large UI Gamma Effect', state.draft.largeUiGammaTrial)" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setLargeUiGammaTrial(value)" />
|
||||
<p class="gx-note">The large UI road-camera color effect on Android Auto (not shown in the preview).</p>
|
||||
</section>
|
||||
<section v-if="!projection && displayGroups.length" v-show="state.inspectorPanel === 'display'" class="gx-layout__colors gx-layout__display" aria-label="Comma screen options">
|
||||
<div class="gx-layout__subhead"><h4>Comma screen options</h4></div>
|
||||
<p class="gx-note">Only the comma screen uses these, in both the Big and Small layouts unless marked. Android Auto has its own options on the Android Auto tab. Save to apply.</p>
|
||||
<template v-for="group in displayGroups" :key="group.name">
|
||||
<h5 class="gx-layout__display-group">{{ group.name }}</h5>
|
||||
<GalaxySettingRow v-for="(row, index) in group.rows" :key="row.key" :row="displaySettingRow(row)" :index="index"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag || !displayAvailable(row)" :save-value="(_, value) => setDisplay(row.key, value)" />
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<GxDialog v-if="state.removeConfirm && profile" labelledby="gx-layout-remove-title" describedby="gx-layout-remove-body" alert @close="cancelRemove">
|
||||
<div><h3 id="gx-layout-remove-title">Remove {{ profile.widgets[state.removeConfirm]?.label }}?</h3>
|
||||
<p id="gx-layout-remove-body">Remove this widget from the {{ projection ? 'Android Auto' : state.profile === 'large' ? 'Big' : 'Small' }} layout? You can add it again from Inactive Widgets.</p></div>
|
||||
<div class="gx-settings__controls gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="cancelRemove">Cancel</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="confirmRemove">Remove widget</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
<GxDialog v-if="modalLeavePrompt" class="gx-layout__leave-dialog" :inert="busy" labelledby="gx-layout-leave-title" describedby="gx-layout-leave-body" alert @close="cancelLeave">
|
||||
<div class="gx-layout__leave-copy"><h3 id="gx-layout-leave-title">Leave without saving your changes?</h3>
|
||||
<p id="gx-layout-leave-body">Save your {{ projection ? 'Android Auto' : 'comma' }} layout before leaving, keep editing, or discard this draft.</p></div>
|
||||
<GxNotice v-if="state.error" tone="danger">{{ state.error }}</GxNotice>
|
||||
<div class="gx-layout__leave-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="cancelLeave">Keep editing</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="leave(state.discard)">Discard and leave</button>
|
||||
<button type="button" class="gx-btn" :disabled="!saveAvailable" @click="saveAndLeave">{{ state.status === 'saving' ? 'Saving…' : 'Save changes' }}</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
</section>`,
|
||||
template: ONROAD_LAYOUT_TEMPLATE,
|
||||
}
|
||||
|
||||
@@ -143,7 +143,8 @@ class TestBoltManualFriction(unittest.TestCase):
|
||||
controls = Controls()
|
||||
self.assertEqual(controls.LaC.controller_mode, mode)
|
||||
self.assertEqual(controls.torque_host is not None, configured)
|
||||
self.assertEqual(controls.torque_learning_allowed, mode == ControllerMode.STANDARD)
|
||||
# GM retains no native scalar estimator in either selected mode.
|
||||
self.assertFalse(controls.torque_learning_allowed)
|
||||
|
||||
def test_host_friction_ff_fixed_factor_offset_and_live_precedence(self):
|
||||
for identity in BOLT_GENERATIONS:
|
||||
@@ -165,7 +166,9 @@ class TestBoltManualFriction(unittest.TestCase):
|
||||
learned.logMonoTime['lateralTorqueParameters'] = stamp
|
||||
host.apply(controls.LaC, host.sample(learned, now_ns=stamp, lat_active=True))
|
||||
self.assertAlmostEqual(controls.LaC.torque_params.friction, friction, places=6)
|
||||
self.assertAlmostEqual(controls.LaC.torque_params.latAccelOffset, .02 if mode == ControllerMode.STANDARD else basis[1], places=6)
|
||||
self.assertAlmostEqual(controls.LaC.torque_params.latAccelOffset, basis[1], places=6)
|
||||
self.assertFalse(host.allow_learning)
|
||||
self.assertEqual(host.selected.source.value, 'user')
|
||||
if mode == ControllerMode.STARPILOT:
|
||||
self.assertEqual(controls.LaC.torque_from_lateral_accel(1., controls.LaC.torque_params),
|
||||
controls.LaC.torque_from_lateral_accel(1., cp.lateralTuning.torque))
|
||||
@@ -180,7 +183,7 @@ class TestBoltManualFriction(unittest.TestCase):
|
||||
learned.logMonoTime['lateralTorqueParameters'] = stamp
|
||||
host.apply(controls.LaC, host.sample(learned, now_ns=stamp, lat_active=True))
|
||||
self.assertAlmostEqual(controls.LaC.torque_params.friction, basis[2], places=6)
|
||||
self.assertEqual(host.selected.source.value, 'learned' if mode == ControllerMode.STANDARD else 'vehicle')
|
||||
self.assertEqual(host.selected.source.value, 'vehicle')
|
||||
|
||||
def test_unscoped_legacy_and_custom_factor_need_review(self):
|
||||
with OpenpilotPrefix():
|
||||
|
||||
@@ -90,7 +90,8 @@ class TestNativeInferredTorque(unittest.TestCase):
|
||||
selected = read_selection(params, cp)
|
||||
baseline = LatControlTorque(cp.as_reader(), ci, DT_CTRL, controller_mode=selected.mode)
|
||||
expected_learning = learning_allowed(params, cp, selection=selected)
|
||||
self.assertEqual(expected_learning, mode == ControllerMode.STANDARD)
|
||||
# Native refinement is available with either selected controller.
|
||||
self.assertTrue(expected_learning)
|
||||
params.put('SteerLatAccel', float(cp.lateralTuning.torque.latAccelFactor), block=True)
|
||||
params.put('SteerFriction', float(cp.lateralTuning.torque.friction), block=True)
|
||||
self.assertFalse(runtime_enabled(cp, params))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Controller-specific learning is enforced by its producer and both consumers."""
|
||||
"""Native refinement respects saved OFF, manual precedence and startup latching."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -56,7 +56,8 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
cp = cp_for(vehicle)
|
||||
params.remove(DOCUMENT_KEY)
|
||||
params.remove(LEARNING_OFF_KEY)
|
||||
self.assertFalse(learning_allowed(params, cp))
|
||||
# Both selected modes support native refinement without manual edits.
|
||||
self.assertTrue(learning_allowed(params, cp))
|
||||
select(params, cp, ControllerMode.STANDARD)
|
||||
self.assertTrue(learning_allowed(params, cp))
|
||||
for saved in (b'1', b'invalid', b''):
|
||||
@@ -69,7 +70,7 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
self.assertFalse(learning_allowed(params, cp_for(vehicle)))
|
||||
self.assertFalse(learning_allowed(params, cp_for(TOYOTA.TOYOTA_RAV4_TSS2)))
|
||||
|
||||
def test_scoped_learning_choice_preserves_legacy_and_controller_compatibility(self):
|
||||
def test_scoped_learning_choice_preserves_legacy_and_both_controller_modes(self):
|
||||
with OpenpilotPrefix():
|
||||
params, cp = Params(), cp_for(HYUNDAI.HYUNDAI_IONIQ_6)
|
||||
params.put_bool(LEARNING_OFF_KEY, True, block=True)
|
||||
@@ -77,7 +78,7 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
params.put(LEARNING_KEY, replace_preference(None, cp, True), block=True)
|
||||
self.assertTrue(read_preference(params, cp).enabled)
|
||||
self.assertEqual(read_preference(params, cp).source, 'scope')
|
||||
self.assertFalse(learning_allowed(params, cp)) # Vehicle tune remains incompatible.
|
||||
self.assertTrue(learning_allowed(params, cp)) # Scoped ON supports the vehicle controller too.
|
||||
select(params, cp, ControllerMode.STANDARD)
|
||||
self.assertTrue(learning_allowed(params, cp))
|
||||
other = cp_for(TOYOTA.TOYOTA_COROLLA_TSS2)
|
||||
@@ -108,6 +109,9 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
params.put('CarParams', cp.to_bytes(), block=True)
|
||||
cache = Path(params.get_param_path('LiveTorqueParameters'))
|
||||
cache.write_bytes(b'preserved previous learning')
|
||||
# Disabled is an explicit saved user choice, independent of controller mode.
|
||||
params.put_bool(LEARNING_OFF_KEY, True, block=True)
|
||||
self.assertFalse(learning_allowed(params, cp))
|
||||
with mock.patch.object(torqued, 'get_cache') as restore:
|
||||
estimator = torqued.TorqueEstimator(cp, allow_learning=learning_allowed(params, cp))
|
||||
restore.assert_not_called()
|
||||
@@ -170,7 +174,7 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
fit.assert_called_once()
|
||||
self.assertTrue(message.valid)
|
||||
|
||||
def test_direct_controller_consumer_rejects_learned_messages_and_latches_policy(self):
|
||||
def test_direct_controller_accepts_native_refinement_in_both_modes_and_latches_policy(self):
|
||||
now = 1_000_000_000
|
||||
|
||||
def feed_learning(controls):
|
||||
@@ -196,11 +200,12 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
feed_learning(controls)
|
||||
with mock.patch.object(controls.LaC, 'update_torque_parameters') as update:
|
||||
controls.state_control()
|
||||
self.assertEqual(update.call_count, 0 if vehicle in VEHICLES else 1)
|
||||
update.assert_called_once()
|
||||
if vehicle in VEHICLES:
|
||||
select(params, cp, ControllerMode.STANDARD)
|
||||
controls.state_control()
|
||||
update.assert_not_called()
|
||||
self.assertEqual(update.call_count, 2)
|
||||
self.assertTrue(controls.torque_learning_allowed)
|
||||
self.assertEqual(controls.LaC.controller_mode, ControllerMode.STARPILOT)
|
||||
next_drive = Controls()
|
||||
self.assertTrue(next_drive.torque_learning_allowed)
|
||||
@@ -261,7 +266,7 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
self.assertEqual(host._select(learned, now).source, TorqueSource.USER)
|
||||
self.assertFalse(TorqueHost(params, cp, allow_learning=learning_allowed(params, cp)).allow_learning)
|
||||
|
||||
def test_running_manual_host_latches_both_learning_directions_and_keeps_manual_edits(self):
|
||||
def test_running_manual_host_keeps_native_disabled_after_manual_reset_and_keeps_edits(self):
|
||||
with OpenpilotPrefix(), mock.patch.dict(os.environ, ENV), \
|
||||
mock.patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'):
|
||||
params, cp = Params(), cp_for(HYUNDAI.HYUNDAI_IONIQ_6)
|
||||
@@ -270,12 +275,13 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
select(params, cp, ControllerMode.STANDARD)
|
||||
params.put('SteerFriction', cp.lateralTuning.torque.friction * 1.2, block=True)
|
||||
controls = Controls()
|
||||
self.assertFalse(controls.torque_learning_allowed)
|
||||
params.remove('SteerFriction')
|
||||
host, base = controls.torque_host, controls.torque_host.vehicle
|
||||
now = 1_000_000_000
|
||||
learned = LearnedFrame(now, factor=base.lat_accel_factor * 1.2, offset=0.03, friction=base.friction * 1.1)
|
||||
host.sample(learned, now_ns=now, lat_active=True)
|
||||
self.assertEqual(host.selected.source, TorqueSource.LEARNED)
|
||||
self.assertEqual(host.selected.source, TorqueSource.VEHICLE)
|
||||
select(params, cp, ControllerMode.STARPILOT)
|
||||
params.put_bool(LEARNING_OFF_KEY, True, block=True)
|
||||
for tick in range(1, 102):
|
||||
@@ -283,14 +289,14 @@ class TestTorqueLearning(unittest.TestCase):
|
||||
learned.logMonoTime['lateralTorqueParameters'] = stamp
|
||||
host.sample(learned, now_ns=stamp, lat_active=True)
|
||||
self.assertEqual(controls.LaC.controller_mode, ControllerMode.STANDARD)
|
||||
self.assertEqual(host.selected.source, TorqueSource.LEARNED)
|
||||
self.assertEqual(host.selected.source, TorqueSource.VEHICLE)
|
||||
params.put('SteerLatAccel', base.lat_accel_factor * 1.1, block=True)
|
||||
assert host is not None
|
||||
host.last_refresh_ns = None
|
||||
host.sample(learned, now_ns=stamp + 10_000_000, lat_active=True)
|
||||
self.assertEqual(host.selected.source, TorqueSource.USER)
|
||||
self.assertAlmostEqual(host.selected.lat_accel_factor, base.lat_accel_factor * 1.1)
|
||||
self.assertEqual(host.selected.friction, learned.state.frictionCoefficientFiltered)
|
||||
self.assertEqual(host.selected.friction, base.friction)
|
||||
|
||||
next_drive = Controls()
|
||||
disabled = next_drive.torque_host
|
||||
|
||||
@@ -114,7 +114,8 @@ class TestVoltTorquePolicy(unittest.TestCase):
|
||||
controls = Controls()
|
||||
self.assertEqual(controls.LaC.controller_mode, mode)
|
||||
self.assertEqual(selected_policy(controls.LaC) is not None, mode == ControllerMode.STARPILOT)
|
||||
self.assertEqual(controls.torque_learning_allowed, mode == ControllerMode.STANDARD)
|
||||
# Native scalar estimation remains outside the GM brand scope.
|
||||
self.assertFalse(controls.torque_learning_allowed)
|
||||
real_update = controls.VM.update_params
|
||||
for speed, live_ratio in ((0., 15.7), (20., 15.7), (20., 15.7), (35., 16.2)):
|
||||
self.enterContext(patch.object(controls, 'sm', {'carState': SimpleNamespace(vEgo=speed),
|
||||
|
||||
@@ -194,7 +194,7 @@ class ReplayOwner:
|
||||
result['export'] = {key: value for key, value in self.export.items() if key != 'expires'}
|
||||
result['export']['owned'] = client == self.export_client
|
||||
if 'expires' in self.export:
|
||||
result['export']['expiresInSeconds'] = max(0., self.export['expires'] - self.clock())
|
||||
result['export']['expiresInSeconds'] = max(0., cast(float, self.export['expires']) - self.clock())
|
||||
if not owned:
|
||||
for key in ('framePath', 'media', 'codec'):
|
||||
result.pop(key, None)
|
||||
@@ -202,9 +202,9 @@ class ReplayOwner:
|
||||
|
||||
def _expire_export(self, *, replace=False):
|
||||
if (self.export is None or self.worker is not None and self.status.get('purpose') == 'export' or
|
||||
not replace and self.clock() < self.export.get('expires', float('inf'))):
|
||||
not replace and self.clock() < cast(float, self.export.get('expires', float('inf')))):
|
||||
return True
|
||||
directory = self.root / 'exports' / self.export['id']
|
||||
directory = self.root / 'exports' / cast(str, self.export['id'])
|
||||
path = directory / 'replay.mp4'
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Exports keep recorded coverage, remain private, and become ready only after cleanup."""
|
||||
from types import SimpleNamespace
|
||||
from typing import NoReturn
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
@@ -95,16 +96,21 @@ def test_partial_video_and_storage_shortage_cannot_be_finalized(tmp_path, packet
|
||||
assert not (tmp_path / 'export.mp4').exists()
|
||||
|
||||
|
||||
def _reject_engine_resume(_command) -> NoReturn:
|
||||
__tracebackhide__ = True
|
||||
raise pytest.fail.Exception('an ended engine must not resume')
|
||||
|
||||
|
||||
def test_export_finishes_at_actual_engine_eof(tmp_path, packet):
|
||||
from openpilot.tools.replay.tests.test_machine import BINARY, Machine
|
||||
if not BINARY.is_file():
|
||||
pytest.skip('build the replay test binary first')
|
||||
raise pytest.skip.Exception('build the replay test binary first')
|
||||
with Machine([(0, [.1, .2, .3], True)]) as engine:
|
||||
initial = engine.wait(lambda status: status['state'] == 'paused')
|
||||
engine.command_status('pause', 'a4', paused=False)
|
||||
ended = engine.wait(lambda status: status['state'] == 'ended')
|
||||
assert ended['position'] < ended['end']
|
||||
stopped = SimpleNamespace(command=lambda command: pytest.fail('an ended engine must not resume'))
|
||||
stopped = SimpleNamespace(command=_reject_engine_resume)
|
||||
writer = ExportVideo(tmp_path, 32, 18, [(initial['start'], initial['end'])], 1024 ** 2)
|
||||
try:
|
||||
for position in (.1, .2, .3):
|
||||
@@ -119,7 +125,7 @@ def test_export_finishes_at_actual_engine_eof(tmp_path, packet):
|
||||
|
||||
def test_early_engine_eof_cannot_hide_missing_export_coverage(tmp_path, packet):
|
||||
writer = ExportVideo(tmp_path, 32, 18, [(0, 2)], 1024 ** 2)
|
||||
engine = SimpleNamespace(command=lambda command: pytest.fail('an ended engine must not resume'))
|
||||
engine = SimpleNamespace(command=_reject_engine_resume)
|
||||
try:
|
||||
writer.frame(packet, position=0, keyframe=True)
|
||||
with pytest.raises(ValueError, match='end of the selected coverage'):
|
||||
|
||||
+2
-2
@@ -120,10 +120,10 @@ allow-direct-references = true
|
||||
[tool.codespell]
|
||||
quiet-level = 3
|
||||
# if you've got a short variable name that's getting flagged, add it here
|
||||
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor"
|
||||
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor,centre,centres"
|
||||
builtin = "clear,rare,informal,code,names,en-GB_to_en-US"
|
||||
skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt"
|
||||
ignore-regex = "scale\\.numer"
|
||||
ignore-regex = "scale\\.numer" # codespell:ignore numer
|
||||
|
||||
# https://docs.astral.sh/ruff/configuration/#using-pyprojecttoml
|
||||
[tool.ruff]
|
||||
|
||||
@@ -8,10 +8,16 @@ REQUIRED_REPLAY_LABELS = frozenset({
|
||||
"HYUNDAI", "HYUNDAI2", "TOYOTA", "TOYOTA3", "HONDA", "HONDA2", "CHRYSLER", "RAM",
|
||||
"SUBARU", "GM", "NISSAN", "VOLKSWAGEN", "MAZDA", "FORD", "RIVIAN", "TESLA",
|
||||
})
|
||||
VOLVO_UNRECORDED_PLATFORMS = frozenset({
|
||||
"VOLVO_V40", "VOLVO_XC40_RECHARGE", "VOLVO_S60_RECHARGE", "POLESTAR_2",
|
||||
})
|
||||
VOLVO_REQUIRED_TESTS = {
|
||||
"interfaces": "opendbc_repo/opendbc/car/volvo/tests/test_c1.py",
|
||||
"safety-debug": "opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
|
||||
"safety-release": "opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
|
||||
"interfaces": ("opendbc_repo/opendbc/car/volvo/tests/test_c1.py",
|
||||
"opendbc_repo/opendbc/car/volvo/tests/test_cma.py"),
|
||||
"safety-debug": ("opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
|
||||
"opendbc_repo/opendbc/safety/tests/test_volvo_cma.py"),
|
||||
"safety-release": ("opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
|
||||
"opendbc_repo/opendbc/safety/tests/test_volvo_cma.py"),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +31,23 @@ def replay_coverage(active_platforms, labels, suite_targets, *, full_test):
|
||||
absent_labels = REQUIRED_REPLAY_LABELS - labels
|
||||
if absent_labels:
|
||||
errors.append(f"Required replay inputs missing: {sorted(absent_labels)}")
|
||||
if set(active_platforms.get("volvo", ())) != {"VOLVO_V40"}:
|
||||
errors.append("Volvo recording classification requires exactly the active VOLVO_V40 platform; review changed scope")
|
||||
if set(active_platforms.get("volvo", ())) != VOLVO_UNRECORDED_PLATFORMS:
|
||||
errors.append("Volvo recording classification requires exactly the four known active platforms; review changed scope")
|
||||
if "volvo" not in missing:
|
||||
errors.append("Volvo recording classification is stale: Volvo now has a recording or is no longer active")
|
||||
for suite, path in VOLVO_REQUIRED_TESTS.items():
|
||||
if path not in suite_targets.get(suite, ()):
|
||||
errors.append(f"Required Volvo source/native suite no longer selected: {suite}: {path}")
|
||||
for suite, paths in VOLVO_REQUIRED_TESTS.items():
|
||||
for path in paths:
|
||||
if path not in suite_targets.get(suite, ()):
|
||||
errors.append(f"Required Volvo source/native suite no longer selected: {suite}: {path}")
|
||||
unaccounted = missing - {"volvo"}
|
||||
if unaccounted:
|
||||
errors.append(f"Active brands missing unaccounted replay inputs: {sorted(unaccounted)}")
|
||||
if "volvo" in missing:
|
||||
gaps.append({"brand": "volvo", "platforms": ["VOLVO_V40"], "status": "unrecorded",
|
||||
"reason": "No Volvo C1/V40 recording is available in the fixture catalog.",
|
||||
"required_source_native_suites": VOLVO_REQUIRED_TESTS})
|
||||
for platform in sorted(set(active_platforms.get("volvo", ())) & VOLVO_UNRECORDED_PLATFORMS):
|
||||
path_index = 0 if platform == "VOLVO_V40" else 1
|
||||
gaps.append({"brand": "volvo", "platforms": [platform], "status": "unrecorded",
|
||||
"reason": f"No {platform} recording is available in the fixture catalog.",
|
||||
"required_source_native_suites": {suite: paths[path_index] for suite, paths in VOLVO_REQUIRED_TESTS.items()}})
|
||||
return {"schema_version": 1, "scope": "process_replay_input_inventory", "full_test": full_test,
|
||||
"recorded_input_labels": sorted(labels), "active_platforms": active_platforms,
|
||||
"unrecorded_gaps": gaps, "errors": errors,
|
||||
@@ -55,7 +64,8 @@ def write_coverage(report, destination, summary=None):
|
||||
output.write(f"Full input inventory: {report['full_test']}; recorded inputs: {len(report['recorded_input_labels'])}.\n\n")
|
||||
for gap in report["unrecorded_gaps"]:
|
||||
output.write(f"- {gap['brand']} / {', '.join(gap['platforms'])}: **unrecorded**. {gap['reason']}\n")
|
||||
output.write(" Required independent source/native checks: interfaces, safety-debug, safety-release.\n")
|
||||
for suite, path in gap["required_source_native_suites"].items():
|
||||
output.write(f" Required independent source/native check: {suite}: `{path}`.\n")
|
||||
for error in report["errors"]:
|
||||
output.write(f"- Coverage error: {error}\n")
|
||||
output.write(f"\n{report['evidence_limit']}\n")
|
||||
|
||||
@@ -74,6 +74,7 @@ RELEASE_TARGETS = (
|
||||
"opendbc_repo/opendbc/safety/tests/test_tesla.py::TestTeslaStockSafety",
|
||||
"opendbc_repo/opendbc/safety/tests/test_tesla_hw1.py",
|
||||
"opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
|
||||
"opendbc_repo/opendbc/safety/tests/test_volvo_cma.py",
|
||||
"opendbc_repo/opendbc/safety/tests/test_mazda.py::TestMazdaSafety",
|
||||
"opendbc_repo/opendbc/safety/tests/test_toyota.py::TestToyotaStockLongitudinalTorque",
|
||||
"opendbc_repo/opendbc/safety/tests/test_toyota_auto_hold.py",
|
||||
|
||||
@@ -3,14 +3,14 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tools.ci.process_replay_coverage import REQUIRED_REPLAY_LABELS, VOLVO_REQUIRED_TESTS, replay_coverage, write_coverage
|
||||
from tools.ci.process_replay_coverage import REQUIRED_REPLAY_LABELS, VOLVO_REQUIRED_TESTS, VOLVO_UNRECORDED_PLATFORMS, replay_coverage, write_coverage
|
||||
from tools.ci.run_vehicle_tests import ROOT, targets_for
|
||||
|
||||
|
||||
class TestProcessReplayCoverage(unittest.TestCase):
|
||||
def report(self, platforms=None, labels=None, targets=None):
|
||||
active = {label.lower(): [label] for label in REQUIRED_REPLAY_LABELS}
|
||||
active["volvo"] = ["VOLVO_V40"]
|
||||
active["volvo"] = sorted(VOLVO_UNRECORDED_PLATFORMS)
|
||||
return replay_coverage(active if platforms is None else platforms,
|
||||
REQUIRED_REPLAY_LABELS if labels is None else labels,
|
||||
{suite: targets_for(suite) for suite in VOLVO_REQUIRED_TESTS} if targets is None else targets,
|
||||
@@ -24,24 +24,60 @@ class TestProcessReplayCoverage(unittest.TestCase):
|
||||
self.assertTrue(self.report(labels=REQUIRED_REPLAY_LABELS - {label})["errors"])
|
||||
|
||||
def test_actual_vehicle_suite_targets_include_required_source_and_both_native_modes(self):
|
||||
for suite, path in VOLVO_REQUIRED_TESTS.items():
|
||||
self.assertIn(path, targets_for(suite))
|
||||
module = ast.parse((ROOT / VOLVO_REQUIRED_TESTS["interfaces"]).read_text())
|
||||
for suite, paths in VOLVO_REQUIRED_TESTS.items():
|
||||
for path in paths:
|
||||
with self.subTest(suite=suite, path=path):
|
||||
self.assertIn(path, targets_for(suite))
|
||||
self.assertTrue((ROOT / path).is_file())
|
||||
module = ast.parse((ROOT / VOLVO_REQUIRED_TESTS["interfaces"][0]).read_text())
|
||||
self.assertTrue(any(isinstance(node, ast.ClassDef) and node.name == "TestVolvoC1Host" for node in module.body))
|
||||
|
||||
def test_unknown_brand_changed_platform_and_obsolete_exception_fail(self):
|
||||
active = self.report()["active_platforms"]
|
||||
for change in ({**active, "new_brand": ["NEW_CAR"]},
|
||||
{**active, "volvo": ["VOLVO_V40", "VOLVO_NEW"]},
|
||||
{**active, "volvo": [*active["volvo"], "VOLVO_NEW"]},
|
||||
{brand: ids for brand, ids in active.items() if brand != "volvo"}):
|
||||
self.assertTrue(self.report(platforms=change)["errors"])
|
||||
self.assertTrue(self.report(labels=REQUIRED_REPLAY_LABELS | {"VOLVO"})["errors"])
|
||||
|
||||
def test_required_alternative_suite_removal_fails(self):
|
||||
targets = {suite: targets_for(suite) for suite in VOLVO_REQUIRED_TESTS}
|
||||
for suite, path in VOLVO_REQUIRED_TESTS.items():
|
||||
removed = {**targets, suite: [target for target in targets[suite] if target != path]}
|
||||
self.assertTrue(self.report(targets=removed)["errors"])
|
||||
for suite, paths in VOLVO_REQUIRED_TESTS.items():
|
||||
for path in paths:
|
||||
with self.subTest(suite=suite, path=path):
|
||||
removed = {**targets, suite: [target for target in targets[suite] if target != path]}
|
||||
self.assertTrue(self.report(targets=removed)["errors"])
|
||||
|
||||
def test_frozen_platform_inventory_matches_current_registry_source(self):
|
||||
expected = {"VOLVO_V40", "VOLVO_XC40_RECHARGE", "VOLVO_S60_RECHARGE", "POLESTAR_2"}
|
||||
self.assertEqual(VOLVO_UNRECORDED_PLATFORMS, expected)
|
||||
module = ast.parse((ROOT / "opendbc_repo/opendbc/car/volvo/values.py").read_text())
|
||||
car = next(node for node in module.body if isinstance(node, ast.ClassDef) and node.name == "CAR")
|
||||
declared = {target.id for node in car.body if isinstance(node, ast.Assign)
|
||||
for target in node.targets if isinstance(target, ast.Name)}
|
||||
self.assertEqual(declared, expected)
|
||||
|
||||
def test_each_platform_gap_names_its_independent_source_and_native_alternatives(self):
|
||||
report = self.report()
|
||||
gaps = report["unrecorded_gaps"]
|
||||
self.assertEqual(len(gaps), 4)
|
||||
self.assertEqual({gap["platforms"][0] for gap in gaps}, VOLVO_UNRECORDED_PLATFORMS)
|
||||
for gap in gaps:
|
||||
with self.subTest(platform=gap["platforms"]):
|
||||
self.assertEqual(len(gap["platforms"]), 1)
|
||||
self.assertEqual(gap["status"], "unrecorded")
|
||||
index = 0 if gap["platforms"] == ["VOLVO_V40"] else 1
|
||||
self.assertEqual(gap["required_source_native_suites"],
|
||||
{suite: paths[index] for suite, paths in VOLVO_REQUIRED_TESTS.items()})
|
||||
self.assertIn("independent execution results", report["evidence_limit"])
|
||||
self.assertIn("does not qualify the fleet", report["evidence_limit"])
|
||||
|
||||
def test_missing_known_platform_requires_scope_review(self):
|
||||
active = self.report()["active_platforms"]
|
||||
for platform in VOLVO_UNRECORDED_PLATFORMS:
|
||||
with self.subTest(platform=platform):
|
||||
changed = {**active, "volvo": [item for item in active["volvo"] if item != platform]}
|
||||
self.assertTrue(self.report(platforms=changed)["errors"])
|
||||
|
||||
def test_artifact_and_summary_expose_gap_without_execution_claim(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -49,5 +85,10 @@ class TestProcessReplayCoverage(unittest.TestCase):
|
||||
summary = Path(temporary) / "summary.md"
|
||||
write_coverage(self.report(), destination, summary)
|
||||
self.assertIn('"scope": "process_replay_input_inventory"', destination.read_text())
|
||||
self.assertIn("**unrecorded**", summary.read_text())
|
||||
self.assertEqual(summary.read_text().count("**unrecorded**"), 4)
|
||||
for platform in VOLVO_UNRECORDED_PLATFORMS:
|
||||
self.assertIn(platform, summary.read_text())
|
||||
for paths in VOLVO_REQUIRED_TESTS.values():
|
||||
for path in paths:
|
||||
self.assertIn(path, summary.read_text())
|
||||
self.assertIn("does not qualify the fleet", summary.read_text())
|
||||
|
||||
Reference in New Issue
Block a user