Repair CI Contracts

This commit is contained in:
firestar5683
2026-10-19 00:47:35 -05:00
parent 73003793a0
commit 3a8dbe75f1
46 changed files with 1000 additions and 630 deletions
@@ -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.
+3
View File
@@ -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
+3
View File
@@ -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]
+4 -4
View File
@@ -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:
+12 -1
View File
@@ -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
+5 -2
View File
@@ -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:
+12 -1
View File
@@ -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.
+30 -3
View File
@@ -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
+2
View File
@@ -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
+5 -3
View File
@@ -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
+18 -1
View File
@@ -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')}))
+4 -2
View File
@@ -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)
+6 -2
View File
@@ -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')
+4 -2
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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),
+3 -3
View File
@@ -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
View File
@@ -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]
+22 -12
View File
@@ -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")
+1
View File
@@ -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",
+51 -10
View File
@@ -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())