Compare commits

..

35 Commits

Author SHA1 Message Date
whoisdomi c1326c52af Curve: fix live freshness, hold through stale frames, keep NoLead gate closed after lead resets 2026-10-15 08:26:29 -05:00
firestarsdog 1a137e3af2 Replay 50ms pacing 2026-10-15 05:21:47 -04:00
firestarsdog b5b6aa4cb9 Keep replay camera frames stable during rendering 2026-10-15 04:16:36 -04:00
firestar5683 32fee7f2fe Fix mocked driver-monitor graphics fixtures 2026-10-15 02:09:55 -05:00
firestarsdog f1af611791 Render Optimizations 2026-10-15 03:02:01 -04:00
firestar5683 b16b9fa01f Repair UI intake CI typing and spelling 2026-10-15 01:27:14 -05:00
firestarsdog cfc0eca07a Replay diagnostic checkpoint 2026-10-15 01:56:39 -04:00
firestar5683 ef351ac8c6 Fix validated Replay clock typing 2026-10-15 00:45:52 -05:00
prabhaavp 584669d4c0 Merge pull request #217 from dirwin31:Stardust-PR-10
AA Layouts, and refinements
2026-10-15 01:41:22 -04:00
Prabhaav Pillai e858b53349 Android Auto page with version display 2026-10-15 01:39:03 -04:00
firestarsdog c924a26228 Add Replay compositor trace 2026-10-15 01:24:07 -04:00
firestar5683 ad09863184 Reduce message health aggregation overhead 2026-10-14 23:40:40 -05:00
firestar5683 1ef2045f9c Accelerate single-byte CAN packing 2026-10-14 23:26:56 -05:00
firestar5683 329e82bf88 Reduce AOL wire decoding overhead 2026-10-14 22:29:17 -05:00
dirwin31 c807ff3d45 UI Consistancy, Tighten Boxes 2026-10-14 19:32:28 -07:00
dirwin31 8219f0f879 Render Faster Align Better 2026-10-14 19:32:28 -07:00
dirwin31 5c36c5cfb6 Use visibility for scene rendering 2026-10-14 19:32:28 -07:00
dirwin31 8403f6b879 Hide identical mici screens 2026-10-14 19:32:28 -07:00
dirwin31 de2989e737 Remove sample chips and move quick select down 2026-10-14 19:32:28 -07:00
dirwin31 5c02efcc3d Complete bookmark and SLC display dependencies for Stardust PR-10 2026-10-14 19:32:28 -07:00
dirwin31 12fc0dce20 shrink CEM gauge bounding box 2026-10-14 19:32:28 -07:00
dirwin31 96f9af0166 Combine Speed Sources into card. allow drawer, allow L, C, R alignment 2026-10-14 19:32:28 -07:00
dirwin31 44fa7866ac Add cruise card background opacity and speed source config for layouts 2026-10-14 19:32:28 -07:00
dirwin31 4fd26853dd Unify Editor and Layout Scenes 2026-10-14 19:32:28 -07:00
dirwin31 fb37a277ae Dont flicker SLC Limit 2026-10-14 19:32:28 -07:00
dirwin31 7cf46c9a0a Keep Showing Icons and show SP Watermark on widget editor 2026-10-14 19:32:28 -07:00
dirwin31 012efe06b7 Allow bookmarks with off Screen 2026-10-14 19:32:28 -07:00
dirwin31 88e85ed46e Add StarPilot logo and purple theme to AA standby screen 2026-10-14 19:32:28 -07:00
dirwin31 abf3eb99b6 Auto-reload Galaxy after device reboot or update 2026-10-14 19:32:28 -07:00
dirwin31 ad0bbf6871 Dont Direct to APK Mirror Right Away 2026-10-14 19:32:28 -07:00
dirwin31 ba9fab8cca Move Mobile Align text out of edit window. 2026-10-14 19:32:28 -07:00
dirwin31 1169223081 Store and Show Current APK App Version 2026-10-14 19:32:28 -07:00
dirwin31 20c036da0d Tidy some Redundancy 2026-10-14 19:32:28 -07:00
dirwin31 362e23ee45 Add widget snapping functionality to onroad layout editor 2026-10-14 19:32:28 -07:00
dirwin31 34658746a8 Separate Android Auto lead indicator and lead info from the comma screen 2026-10-14 19:32:28 -07:00
125 changed files with 4723 additions and 1335 deletions
+12
View File
@@ -56,6 +56,18 @@ class CANPacker:
def set_value(msg: bytearray, sig: Signal, ival: int) -> None:
if type(msg) is bytearray and type(sig) is Signal and type(ival) is int:
lsb = getattr(sig, 'lsb', None)
size = getattr(sig, 'size', None)
little_endian = getattr(sig, 'is_little_endian', None)
if type(lsb) is int and type(size) is int and type(little_endian) is bool:
i, shift = lsb // 8, lsb % 8
if 0 < size <= 8 - shift and 0 <= i < len(msg):
field_mask = (1 << size) - 1
mask = field_mask << shift
msg[i] = (msg[i] & ~mask) | ((ival & field_mask) << shift)
return
i = sig.lsb // 8
bits = sig.size
if sig.size < 64:
@@ -0,0 +1,186 @@
"""Single-byte packing preserves bits and the original fallback semantics."""
import unittest
from opendbc.can.dbc import Signal
from opendbc.can.packer import set_value
def reference_set_value(msg, sig, ival):
# Frozen pre-optimization setter; the private AST checker binds this to source.
i = sig.lsb // 8
bits = sig.size
if sig.size < 64:
ival &= (1 << sig.size) - 1
while 0 <= i < len(msg) and bits > 0:
shift = sig.lsb % 8 if (sig.lsb // 8) == i else 0
size = min(bits, 8 - shift)
mask = ((1 << size) - 1) << shift
msg[i] &= ~mask
msg[i] |= (ival & ((1 << size) - 1)) << shift
bits -= size
ival >>= size
i = i + 1 if sig.is_little_endian else i - 1
def signal(lsb, size, little=True, kind=Signal):
return kind('TEST', lsb, lsb + size - 1, lsb, size, False, 1., 0., little)
def observed(function, factory):
msg, sig, value, events = factory()
try:
result = function(msg, sig, value)
error = None
except Exception as exc:
result, error = None, (type(exc).__name__, str(exc))
return bytes(msg), result, error, events
class TestPackerSingleByte(unittest.TestCase):
def compare(self, factory):
self.assertEqual(observed(set_value, factory), observed(reference_set_value, factory))
def test_contained_fields_preserve_neighbor_bits_both_endiannesses(self):
values = (0, 1, -1, 0x55, 0xAA, 1 << 96, -(1 << 96))
for little in (False, True):
for initial in (0, 0xA5, 255):
for shift in range(8):
for size in range(1, 9 - shift):
for value in values:
with self.subTest(little=little, initial=initial, shift=shift, size=size, value=value):
msg = bytearray((0x3C, initial, 0xC3))
mask = ((1 << size) - 1) << shift
self.assertIsNone(set_value(msg, signal(8 + shift, size, little), value))
self.assertEqual(msg, bytearray((0x3C, (initial & ~mask) | ((value << shift) & mask), 0xC3)))
def test_cross_byte_wide_zero_negative_and_clipped_geometry(self):
geometry = ((0, 0), (0, -1), (-1, 1), (-8, 8), (8, 1), (24, 8),
(0, 9), (7, 2), (4, 16), (0, 63), (0, 64), (7, 65), (16, 128))
for little in (False, True):
for lsb, size in geometry:
for length in (0, 1, 3, 17):
for value in (-1, 0, 0x55, 1 << 130, -(1 << 130)):
with self.subTest(little=little, lsb=lsb, size=size, length=length, value=value):
self.compare(lambda length=length, lsb=lsb, size=size, little=little, value=value:
(bytearray([0xA5] * length), signal(lsb, size, little), value, []))
def test_mutated_geometry_is_read_for_each_call(self):
sig = signal(0, 1)
for lsb, size, little in ((0, 1, True), (9, 6, False), (7, 9, True), (16, 64, False)):
sig.lsb, sig.size, sig.is_little_endian = lsb, size, little
expected, actual = bytearray(b'\xA5\x5A\x96'), bytearray(b'\xA5\x5A\x96')
reference_set_value(expected, sig, -7)
set_value(actual, sig, -7)
self.assertEqual(actual, expected)
def test_nonexact_buffers_preserve_fallback_writes_and_errors(self):
class Buffer(bytearray):
def __getitem__(self, key):
self.events.append(('get', key))
return super().__getitem__(key)
def __setitem__(self, key, value):
self.events.append(('set', key, value))
return super().__setitem__(key, value)
def tracked():
msg = Buffer(b'\xA5\x5A')
msg.events = []
return msg, signal(2, 3), 7, msg.events
self.compare(tracked)
for container in (bytes, list, lambda data: memoryview(bytearray(data))):
self.compare(lambda container=container: (container(b'\xA5\x5A'), signal(2, 3), 7, []))
def test_signal_subclass_attribute_side_effects_keep_original_order(self):
class TrackedSignal(Signal):
def __getattribute__(self, name):
if name in ('lsb', 'size', 'is_little_endian'):
object.__getattribute__(self, 'events').append(name)
return super().__getattribute__(name)
def factory():
sig = signal(3, 10, False, TrackedSignal)
sig.events = []
return bytearray(b'\xA5\x5A'), sig, -1, sig.events
self.compare(factory)
def test_nonexact_integer_values_and_fields_keep_original_operations(self):
class Integer(int):
def __and__(self, other):
self.events.append(('and', other))
return int(self) & other
def __floordiv__(self, other):
self.events.append(('floordiv', other))
return int(self) // other
def __mod__(self, other):
self.events.append(('mod', other))
return int(self) % other
def __lt__(self, other):
self.events.append(('lt', other))
return int(self) < other
for field in ('value', 'lsb', 'size'):
def factory(field=field):
events = []
number = Integer({'value': 7, 'lsb': 2, 'size': 3}[field])
number.events = events
sig = signal(number if field == 'lsb' else 2, number if field == 'size' else 3)
return bytearray(b'\xA5\x5A'), sig, number if field == 'value' else 7, events
self.compare(factory)
for value in (True, False, 1.5, None):
self.compare(lambda value=value: (bytearray(b'\xA5'), signal(2, 3), value, []))
for lsb, size, little in ((True, 3, True), (2, True, False), (2., 3, True),
(2, 3., True), (2, 3, 1), (2, 3, None)):
self.compare(lambda lsb=lsb, size=size, little=little: (bytearray(b'\xA5\x5A'), signal(lsb, size, little), 7, []))
def test_nonboolean_endian_truth_side_effects_keep_fallback_order(self):
class Truth:
def __init__(self, events):
self.events = events
def __bool__(self):
self.events.append('bool')
return False
def factory():
events = []
return bytearray(b'\xA5\x5A'), signal(8, 9, Truth(events)), 5, events
self.compare(factory)
def test_deleted_signal_fields_preserve_errors_and_unread_endian(self):
for field in ('lsb', 'size', 'is_little_endian'):
for lsb, size, length in ((2, 3, 1), (2, 0, 1), (16, 3, 1), (2, 3, 0)):
def factory(lsb=lsb, size=size, field=field, length=length):
sig = signal(lsb, size)
delattr(sig, field)
return bytearray([0xA5] * length), sig, 7, []
with self.subTest(field=field, lsb=lsb, size=size, length=length):
self.compare(factory)
class DivisionProbe:
def __init__(self, events):
self.events = events
def __floordiv__(self, other):
self.events.append(('floordiv', other))
raise RuntimeError('original lsb operation precedes missing size')
def missing_size():
events = []
sig = signal(2, 3)
sig.lsb = DivisionProbe(events)
del sig.size
return bytearray(b'\xA5'), sig, 7, events
self.compare(missing_size)
if __name__ == '__main__':
unittest.main()
+33 -3
View File
@@ -268,13 +268,43 @@ class SubMaster:
self.freq_ok[s] = self.freq_tracker[s].valid or self.simulation
def all_alive(self, service_list: list[str] | None = None) -> bool:
return all(self.alive[s] for s in (service_list or self.services) if s not in self.ignore_alive)
for s in (service_list or self.services):
try:
if s in self.ignore_alive:
continue
value = self.alive[s]
except StopIteration as exc:
raise RuntimeError('generator raised StopIteration') from exc
if not value:
return False
del value
return True
def all_freq_ok(self, service_list: list[str] | None = None) -> bool:
return all(self.freq_ok[s] for s in (service_list or self.services) if self._check_avg_freq(s))
for s in (service_list or self.services):
try:
if not self._check_avg_freq(s):
continue
value = self.freq_ok[s]
except StopIteration as exc:
raise RuntimeError('generator raised StopIteration') from exc
if not value:
return False
del value
return True
def all_valid(self, service_list: list[str] | None = None) -> bool:
return all(self.valid[s] for s in (service_list or self.services) if s not in self.ignore_valid)
for s in (service_list or self.services):
try:
if s in self.ignore_valid:
continue
value = self.valid[s]
except StopIteration as exc:
raise RuntimeError('generator raised StopIteration') from exc
if not value:
return False
del value
return True
def all_checks(self, service_list: list[str] | None = None) -> bool:
return self.all_alive(service_list) and self.all_freq_ok(service_list) and self.all_valid(service_list)
@@ -0,0 +1,144 @@
"""Health aggregation is fresh and preserves lookup and exception ordering."""
import random
import unittest
from openpilot.cereal.messaging import SubMaster
from openpilot.cereal.services import SERVICE_LIST
SERVICES = ['carState', 'deviceState', 'carParams']
METHODS = ('all_alive', 'all_freq_ok', 'all_valid', 'all_checks')
class Reference:
def _check_avg_freq(self, s):
return SERVICE_LIST[s].frequency > 0.99 and (s not in self.ignore_average_freq) and (s not in self.ignore_alive)
def all_alive(self, service_list=None):
return all(self.alive[s] for s in (service_list or self.services) if s not in self.ignore_alive)
def all_freq_ok(self, service_list=None):
return all(self.freq_ok[s] for s in (service_list or self.services) if self._check_avg_freq(s))
def all_valid(self, service_list=None):
return all(self.valid[s] for s in (service_list or self.services) if s not in self.ignore_valid)
def all_checks(self, service_list=None):
return self.all_alive(service_list) and self.all_freq_ok(service_list) and self.all_valid(service_list)
def host(kind, seed=0):
obj, rng = kind.__new__(kind), random.Random(seed)
obj.services, obj.events = SERVICES[:], []
for name in ('alive', 'freq_ok', 'valid'):
setattr(obj, name, {s: bool(rng.getrandbits(1)) for s in SERVICES})
for name in ('ignore_alive', 'ignore_average_freq', 'ignore_valid'):
setattr(obj, name, [s for s in SERVICES if rng.randrange(4) == 0])
return obj
def outcome(obj, method, selected):
try:
return getattr(obj, method)(selected), obj.events
except Exception as exc:
return (type(exc).__name__, str(exc), type(exc.__cause__).__name__, exc.__suppress_context__), obj.events
def traced(kind, fail=None, error=KeyError, first_false=False):
obj = host(kind)
def touch(operation, key):
obj.events.append((operation, key))
if operation == fail and key == 'deviceState':
raise error('probe')
class Truth:
def __init__(self, key): self.key = key
def __bool__(self):
touch('truth', self.key)
return not (first_false and self.key == 'carState')
class Mapping(dict):
def __getitem__(self, key):
touch('get', key)
return super().__getitem__(key)
class Ignored(list):
def __contains__(self, key):
touch('contains', key)
return super().__contains__(key)
for name in ('alive', 'freq_ok', 'valid'):
setattr(obj, name, Mapping({s: Truth(s) for s in SERVICES}))
for name in ('ignore_alive', 'ignore_average_freq', 'ignore_valid'):
setattr(obj, name, Ignored())
return obj
class TestHealthAggregation(unittest.TestCase):
def test_deterministic_maps_selection_ignores_and_mutation(self):
for seed in range(16):
for selected in (None, [], SERVICES[:2], [SERVICES[0], SERVICES[0]]):
a, b = host(SubMaster, seed), host(Reference, seed)
for method in METHODS:
self.assertEqual(outcome(a, method, selected), outcome(b, method, selected))
for name in ('alive', 'freq_ok', 'valid'):
getattr(a, name)['carState'] = not getattr(a, name)['carState']
getattr(b, name)['carState'] = not getattr(b, name)['carState']
self.assertEqual(outcome(a, method, selected), outcome(b, method, selected))
def test_custom_lookup_truth_membership_and_first_failure_order(self):
for fail in (None, 'get', 'contains', 'truth'):
for error in (KeyError, StopIteration):
for first_false in (False, True):
for method in METHODS:
a, b = (traced(kind, fail, error, first_false) for kind in (SubMaster, Reference))
self.assertEqual(outcome(a, method, None), outcome(b, method, None))
for method in METHODS:
self.assertEqual(outcome(traced(SubMaster), method, ['unknown']), outcome(traced(Reference), method, ['unknown']))
def test_selection_stopiteration_stays_outside_generator(self):
class TruthRaises(list):
def __bool__(self): raise StopIteration('selection truth')
class IterRaises(list):
def __iter__(self): raise StopIteration('selection iter')
for selected in (TruthRaises(SERVICES), IterRaises(SERVICES)):
for method in METHODS:
self.assertEqual(outcome(host(SubMaster), method, selected), outcome(host(Reference), method, selected))
def test_ephemeral_truthy_value_is_released_before_next_lookup(self):
def factory(kind):
obj = host(kind)
class Ephemeral:
def __init__(self, key): self.key = key
def __bool__(self):
obj.events.append(('truth', self.key))
return True
def __del__(self):
obj.events.append(('del', self.key))
class Mapping:
def __getitem__(self, key):
obj.events.append(('get', key))
return Ephemeral(key)
for name in ('alive', 'freq_ok', 'valid'):
setattr(obj, name, Mapping())
for name in ('ignore_alive', 'ignore_average_freq', 'ignore_valid'):
setattr(obj, name, [])
return obj
for method in METHODS:
a, b = factory(SubMaster), factory(Reference)
self.assertEqual(outcome(a, method, None), outcome(b, method, None))
self.assertEqual(a.events[:6], [('get', 'carState'), ('truth', 'carState'), ('del', 'carState'),
('get', 'deviceState'), ('truth', 'deviceState'), ('del', 'deviceState')])
def test_all_checks_retains_subclass_dispatch_and_short_circuit(self):
selected = SERVICES[:1]
for stop in range(4):
results = []
for base in (SubMaster, Reference):
def override(index, stop=stop):
def call(obj, services):
obj.events.append((METHODS[index], services is selected))
return index != stop
return call
cls = type('Overridden', (base,), {name: override(i) for i, name in enumerate(METHODS[:3])})
results.append(outcome(host(cls), 'all_checks', selected))
self.assertEqual(*results)
if __name__ == '__main__':
unittest.main()
+10 -17
View File
@@ -53,7 +53,7 @@ from opendbc.car.ford.aol import (
from opendbc.car.hyundai.canfd_angle_aol import native_observation, temporary_restriction, qualified as qualified_angle_aol
from opendbc.car.hyundai.canfd_owner import config_index as hyundai_canfd_config_index
from opendbc.car.hyundai.captured_adrv_startup import CapturedADRVStartup
from openpilot.starpilot.car.hyundai.aol import aol_safety_config as hyundai_aol_safety_config, qualified_ioniq6
from openpilot.starpilot.car.hyundai.aol import aol_safety_config as hyundai_aol_safety_config
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, current_axis, current_native
from openpilot.starpilot.aol.vehicle import create_intent as create_aol_intent, native_latch_rejected, policy_for as aol_policy_for
from openpilot.starpilot.aol.wire import IntentState, encode_intent
@@ -773,25 +773,18 @@ class Car:
def aol_honda_brake_source(self, CS, now_ns: int) -> int:
observation = getattr(self, '_aol_can_observation', None)
if not CS.brakePressed or observation is None or observation[0] is not CS:
if (not CS.brakePressed or not qualified_honda_stock_aol(self.CP, marked_only=True) or
observation is None or observation[0] is not CS):
return 0
from opendbc.car import Bus
from opendbc.car.honda.values import HondaFlags
parser = self.CI.can_parsers[Bus.pt]
if qualified_honda_stock_aol(self.CP, marked_only=True):
from opendbc.car.honda.values import HondaFlags
message = 'BRAKE_MODULE' if self.CP.flags & HondaFlags.BOSCH_ALT_BRAKE else 'POWERTRAIN_DATA'
signals = parser.vl.get(message, {})
# Match the real parser's applied-brake or two-sample switch decision.
brake = bool(signals.get('BRAKE_PRESSED', 0) or message == 'POWERTRAIN_DATA' and
signals.get('BRAKE_SWITCH', 0) and self.CI.CS.brake_switch_active)
stamp = int(parser.ts_nanos.get(message, {}).get('BRAKE_PRESSED', 0))
elif qualified_ioniq6(self.CP):
# Panda revokes LONG on this brake one frame before the host withdraws its
# request; the source lets that window retain LAT intent like Honda's.
brake = parser.vl.get('TCS', {}).get('DriverBraking', 0) == 1
stamp = int(parser.ts_nanos.get('TCS', {}).get('DriverBraking', 0))
else:
return 0
message = 'BRAKE_MODULE' if self.CP.flags & HondaFlags.BOSCH_ALT_BRAKE else 'POWERTRAIN_DATA'
signals = parser.vl.get(message, {})
# Match the real parser's applied-brake or two-sample switch decision.
brake = bool(signals.get('BRAKE_PRESSED', 0) or message == 'POWERTRAIN_DATA' and
signals.get('BRAKE_SWITCH', 0) and self.CI.CS.brake_switch_active)
stamp = int(parser.ts_nanos.get(message, {}).get('BRAKE_PRESSED', 0))
_, received_ns, can_source_ns, boot_ns = observation
# CANParser/CAN batch use BOOTTIME; retain the original MONOTONIC receipt.
if (not brake or not 0 < received_ns <= now_ns or now_ns - received_ns > 30_000_000 or
@@ -229,11 +229,12 @@ class AugmentedRoadView(CameraView):
def render_camera_model_layer(self, rect: rl.Rectangle, *, camera_view: int = CAMERA_VIEW_AUTO,
reverse_driver: bool = False, lead_indicator: bool = False,
lead_info_mode: int = 0, lead_info_metric: bool | None = None,
lateral_active: bool = False, road_style: dict | None = None, paint: bool = True) -> None:
lateral_active: bool = False, road_style: dict | None = None, paint: bool = True,
poll_camera: bool = True) -> None:
"""Draw the existing calibrated camera/model without stock HUD or alert."""
if not ui_state.started and ui_state.replay_clock is None:
return
if camera_view == CAMERA_VIEW_DRIVER or reverse_driver:
if poll_camera and (camera_view == CAMERA_VIEW_DRIVER or reverse_driver):
self._ensure_connection()
target = self._switch_stream_if_needed(ui_state.sm, camera_view, reverse_driver)
if target is None:
@@ -241,7 +242,7 @@ class AugmentedRoadView(CameraView):
rl.draw_rectangle_rec(rect, rl.BLACK)
return
driver_transition = DRIVER_CAM in (self.stream_type, target, self._target_stream_type)
if self._switching and driver_transition:
if poll_camera and self._switching and driver_transition:
self._handle_switch()
if (target == DRIVER_CAM and DRIVER_CAM not in self.available_streams) or \
(driver_transition and self.stream_type != target):
@@ -254,7 +255,7 @@ class AugmentedRoadView(CameraView):
self._update_calibration()
self._content_rect = rect
self._model_renderer.road_style = road_style or {}
gui_app.measure_frame_phase("camera", CameraView._render, self, rect, paint=paint)
gui_app.measure_frame_phase("camera", CameraView._render, self, rect, paint=paint, poll_camera=poll_camera)
if target != DRIVER_CAM and self.frame is not None:
gui_app.measure_frame_phase("model", self._model_renderer.render_with_lead, rect, lead_indicator,
lead_info_mode, lead_info_metric, lateral_active=lateral_active, paint=paint)
@@ -292,9 +293,9 @@ class AugmentedRoadView(CameraView):
if not self._bookmark_icon.interacting():
super()._handle_mouse_release(mouse_pos)
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True):
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True, poll_camera: bool = True):
if not paint:
super()._render(rect, paint=False)
super()._render(rect, paint=False, poll_camera=poll_camera)
return
# Draw text if not onroad
if not ui_state.started:
@@ -325,7 +326,7 @@ class AugmentedRoadView(CameraView):
)
# Render the base camera view
super()._render(self._content_rect)
super()._render(self._content_rect, poll_camera=poll_camera)
# Draw all UI overlays
self._model_renderer.render(self._content_rect)
@@ -227,25 +227,24 @@ class CameraView(Widget):
[0.0, 0.0, 1.0]
])
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True):
def _poll_camera(self) -> bool:
if self._switching:
self._handle_switch()
if not self._ensure_connection():
if paint:
self._draw_placeholder(rect)
return
return False
# Try to get a new buffer without blocking
buffer = self.client.recv(timeout_ms=0)
if buffer:
self._texture_needs_update = True
self.frame = buffer
elif not self.client.is_connected():
# ensure we clear the displayed frame when the connection is lost
self.frame = None
if not self.frame:
return self.frame is not None
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True, poll_camera: bool = True):
if (poll_camera and not self._poll_camera()) or self.frame is None:
if paint:
self._draw_placeholder(rect)
return
@@ -7,11 +7,11 @@ from opendbc.car.structs import car
from dataclasses import dataclass, field
from types import SimpleNamespace
from openpilot.common.params import Params
from openpilot.starpilot.ui.appearance_preferences import format_lead_info
from openpilot.starpilot.ui.rainbow_path import RainbowPath
from openpilot.starpilot.ui.road_colors import lane_color, path_mode, solid_gradient
from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS
from openpilot.starpilot.lateral.lane_feedback import BLUE, direction as lane_centering_direction
from openpilot.common.constants import CV
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA
@@ -221,18 +221,7 @@ class ModelRenderer(Widget):
@staticmethod
def _format_lead_info(lead, mode: int, metric: bool | None) -> str:
if lead is None or not lead.present or mode not in (1, 2) or metric is None:
return ""
try:
value = float(lead.dRel if mode == 1 else lead.vLead)
except (AttributeError, TypeError, ValueError, OverflowError):
return ""
if not math.isfinite(value):
return ""
value = max(value, 0.0)
if mode == 1:
return f"{round(value if metric else value * 3.28084)} {'m' if metric else 'ft'}"
return f"{round(value * (CV.MS_TO_KPH if metric else CV.MS_TO_MPH))} {'km/h' if metric else 'mph'}"
return format_lead_info(lead, mode, metric)
@staticmethod
def _valid_lead_numbers(lead) -> bool:
@@ -48,7 +48,8 @@ class AugmentedRoadView(CameraView):
self.alert_renderer = AlertRenderer()
self.driver_state_renderer = DriverStateRenderer()
def render_camera_model_layer(self, rect: rl.Rectangle, *, road_style: dict | None = None) -> None:
def render_camera_model_layer(self, rect: rl.Rectangle, *, road_style: dict | None = None,
lead_display: tuple[bool, int, bool | None] | None = None, poll_camera: bool = True) -> None:
"""Draw the existing calibrated camera/model without stock HUD or alert."""
if not ui_state.started and ui_state.replay_clock is None:
return
@@ -59,10 +60,11 @@ class AugmentedRoadView(CameraView):
if ("pathEdge" in getattr(self.model_renderer, "road_style", {})) != ("pathEdge" in style):
self.model_renderer._transform_dirty = True
self.model_renderer.road_style = style
gui_app.measure_frame_phase("camera", CameraView._render, self, rect)
self.model_renderer.lead_display = lead_display
gui_app.measure_frame_phase("camera", CameraView._render, self, rect, poll_camera=poll_camera)
gui_app.measure_frame_phase("model", self.model_renderer.render, rect)
def _render(self, rect):
def _render(self, rect, *, poll_camera: bool = True):
# Only render when system is started to avoid invalid data access
if not ui_state.started:
return
@@ -90,7 +92,7 @@ class AugmentedRoadView(CameraView):
)
# Render the base camera view
super()._render(self._content_rect)
super()._render(self._content_rect, poll_camera=poll_camera)
# Draw all UI overlays
self.model_renderer.render(self._content_rect)
@@ -29,8 +29,8 @@ class CabinCameraDialog(CameraView):
def __del__(self):
self.close()
def _render(self, rect):
super()._render(rect)
def _render(self, rect, *, poll_camera: bool = True):
super()._render(rect, poll_camera=poll_camera)
if not self.frame:
gui_label(
+6 -6
View File
@@ -213,24 +213,24 @@ class CameraView(Widget):
[0.0, 0.0, 1.0]
])
def _render(self, rect: rl.Rectangle):
def _poll_camera(self) -> bool:
if self._switching:
self._handle_switch()
if not self._ensure_connection():
self._draw_placeholder(rect)
return
return False
# Try to get a new buffer without blocking
buffer = self.client.recv(timeout_ms=0)
if buffer:
self._texture_needs_update = True
self.frame = buffer
elif not self.client.is_connected():
# ensure we clear the displayed frame when the connection is lost
self.frame = None
if not self.frame:
return self.frame is not None
def _render(self, rect: rl.Rectangle, *, poll_camera: bool = True):
if (poll_camera and not self._poll_camera()) or self.frame is None:
self._draw_placeholder(rect)
return
@@ -6,13 +6,15 @@ from opendbc.car.structs import car
from dataclasses import dataclass, field
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.starpilot.ui.appearance_preferences import format_lead_info
from openpilot.starpilot.ui.rainbow_path import RainbowPath
from openpilot.starpilot.ui.road_colors import edge_gradient, lane_color, path_mode, solid_gradient
from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS
from openpilot.starpilot.lateral.lane_feedback import BLUE, direction as lane_centering_direction
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from openpilot.system.ui.widgets import Widget
@@ -59,6 +61,8 @@ class ModelRenderer(Widget):
self._lane_centering_direction = 0
self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
# (show marker, LeadInfoMode, metric) from a screen with its own lead choices; None keeps the stock chevrons.
self.lead_display: tuple[bool, int, bool | None] | None = None
self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects
@@ -123,7 +127,9 @@ class ModelRenderer(Widget):
model = sm['modelV2']
radar_state = sm['radarState'] if sm.valid['radarState'] else None
lead_one = radar_state.leadOne if radar_state else None
render_lead_indicator = self._longitudinal_control and radar_state is not None
lead_display = self.lead_display
show_lead = self._longitudinal_control if lead_display is None else lead_display[0]
render_lead_indicator = show_lead and radar_state is not None
# Update model data when needed
model_updated = sm.updated['modelV2']
@@ -146,6 +152,10 @@ class ModelRenderer(Widget):
if render_lead_indicator and radar_state:
self._draw_lead_indicator()
if lead_display is not None and self._lead_vehicles[0].chevron:
text = format_lead_info(lead_one, lead_display[1], lead_display[2])
if text:
self._draw_lead_info(self._lead_vehicles[0], text)
def _update_raw_points(self, model):
"""Update raw 3D points from model data"""
@@ -367,6 +377,17 @@ class ModelRenderer(Widget):
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha))
def _draw_lead_info(self, lead: LeadVehicle, text: str) -> None:
# Centered just above the chevron's glow, kept on screen.
font = gui_app.font(FontWeight.SEMI_BOLD)
size = measure_text_cached(font, text, 48)
tip_x, tip_y = lead.glow[1]
x = min(max(tip_x - size.x / 2, self._rect.x), self._rect.x + self._rect.width - size.x)
y = max(tip_y - size.y - 12, self._rect.y)
for dx, dy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
rl.draw_text_ex(font, text, rl.Vector2(x + dx, y + dy), 48, 0, rl.BLACK)
rl.draw_text_ex(font, text, rl.Vector2(x, y), 48, 0, rl.WHITE)
@staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int:
"""Get the index corresponding to the given path distance"""
+8 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import os
import sys
import time
from openpilot.cereal import messaging
from openpilot.common.hardware import COMMA_HARDWARE, HARDWARE
@@ -15,8 +16,12 @@ from openpilot.starpilot.ui.startup import select_ui
BIG_UI = gui_app.big_ui()
def update_frame(preview=None, controllers=None):
def update_frame(preview=None, controllers=None, star=None):
gui_app.measure_frame_phase("ui_state", ui_state.update)
if star is not None:
# Android Auto bookmarks must be consumed even when the comma screen is off
# or another widget covers the StarPilot page.
star._poll_projection_bookmark(time.monotonic_ns())
if controllers is not None:
gui_app.measure_frame_phase("controllers", controllers.poll)
if preview is not None:
@@ -55,8 +60,9 @@ def main():
controllers = None
pm = messaging.PubMaster(['uiDebug'])
star = layout.star if star_ui else None
try:
for should_render, frame_time, cpu_time in gui_app.render(before_frame=lambda: update_frame(preview, controllers)):
for should_render, frame_time, cpu_time in gui_app.render(before_frame=lambda: update_frame(preview, controllers, star)):
if should_render:
if camera_core_ui and not main_thread_configured:
# Share the camera core with normal scheduling, without FIFO display
+2 -7
View File
@@ -487,17 +487,12 @@ class TransportPauseFeedback:
return True
def _honda_brake_lateral_current(self, sm, cp, cs, axis, now_ns, *, latched, brake_source_ns):
"""Preserve existing LAT intent through brake's LONG withdrawal, never output.
Honda stock AOL and the Ioniq 6 both see Panda revoke LONG on brake before
the host withdraws its LONG request."""
"""Preserve existing LAT intent through brake's LONG withdrawal, never output."""
from opendbc.car.honda.stock_aol import qualified
from opendbc.car.structs import car
from openpilot.starpilot.car.hyundai.aol import qualified_ioniq6
source_ns = self.honda_brake_ns or (brake_source_ns if cs.brakePressed else 0)
if (not (qualified(cp, marked_only=True) or qualified_ioniq6(cp)) or
not latched or self.failed or self.paused or self.pending_event or
if (not qualified(cp, marked_only=True) or not latched or self.failed or self.paused or self.pending_event or
not self.session or str(axis.sessionId) != self.session or
not self.healthy_feedback_ns or not 0 <= now_ns - self.healthy_feedback_ns <= AXIS_MAX_AGE_NS or
not 0 < source_ns <= now_ns or now_ns - source_ns > SAFETY_MAX_AGE_NS or
+36 -33
View File
@@ -76,7 +76,7 @@ def _bounded_id(value: str) -> str:
return value
def _payload(raw_value, schema, kind: int, versions=(WIRE_VERSION,)):
def _payload(raw_value, schema, kind: int, materialize, versions=(WIRE_VERSION,)):
if not isinstance(raw_value, (bytes, bytearray, memoryview)):
return None
byte_count = raw_value.nbytes if isinstance(raw_value, memoryview) else len(raw_value)
@@ -92,7 +92,7 @@ def _payload(raw_value, schema, kind: int, versions=(WIRE_VERSION,)):
with schema.from_bytes(raw, traversal_limit_in_words=MAX_WIRE_WORDS, nesting_limit=4) as decoded:
if int(decoded.kind) != kind or int(decoded.version) not in versions:
return None
return decoded.to_dict()
return materialize(decoded)
except (ValueError, OverflowError, RuntimeError, UnicodeDecodeError, capnp.KjException):
return None
@@ -112,27 +112,23 @@ def _memoized(decode):
return wrapper
@_memoized
def decode_safety(raw: bytes) -> SafetyState | None:
value = _payload(raw, custom.AolAxisState.SafetyWire, SAFETY_KIND, (WIRE_VERSION, SAFETY_INVENTORY_VERSION))
if value is None:
return None
def _read_safety(value) -> SafetyState | None:
try:
source_ns = int(value.get('sourcePandaStatesMonoTime', 0))
inventory = tuple(PandaSlot(int(slot['slotIndex']), _bounded_id(str(slot.get('hardwareSerial', ''))),
int(slot['safetyModel']), int(slot['safetyParam']), int(slot['alternativeExperience']),
bool(slot['controlsAllowed']), bool(slot['safetyRxChecksInvalid']),
bool(slot['heartbeatLost']), int(slot['faults']))
for slot in value.get('pandaInventory', ()))
if (int(value['version']) == WIRE_VERSION and (source_ns or inventory) or
int(value['version']) == SAFETY_INVENTORY_VERSION and
source_ns = int(value.sourcePandaStatesMonoTime)
inventory = tuple(PandaSlot(int(slot.slotIndex), _bounded_id(str(slot.hardwareSerial)),
int(slot.safetyModel), int(slot.safetyParam), int(slot.alternativeExperience),
bool(slot.controlsAllowed), bool(slot.safetyRxChecksInvalid),
bool(slot.heartbeatLost), int(slot.faults))
for slot in value.pandaInventory)
if (int(value.version) == WIRE_VERSION and (source_ns or inventory) or
int(value.version) == SAFETY_INVENTORY_VERSION and
(source_ns <= 0 or not 1 <= len(inventory) <= MAX_PANDA_SLOTS)):
return None
result = SafetyState(int(value['protocolVersion']), bool(value['compatible']), int(value['observedMonoTime']),
int(value['validUntilMonoTime']), int(value['safetyModel']), int(value['safetyParam']),
bool(value['lateralAllowed']), bool(value['longitudinalAllowed']), bool(value['requestedLateral']),
bool(value['requestedLongitudinal']), _bounded_id(str(value.get('pandaSerial', ''))),
_bounded_id(str(value.get('axisSessionId', ''))), source_ns, inventory)
result = SafetyState(int(value.protocolVersion), bool(value.compatible), int(value.observedMonoTime),
int(value.validUntilMonoTime), int(value.safetyModel), int(value.safetyParam),
bool(value.lateralAllowed), bool(value.longitudinalAllowed), bool(value.requestedLateral),
bool(value.requestedLongitudinal), _bounded_id(str(value.pandaSerial)),
_bounded_id(str(value.axisSessionId)), source_ns, inventory)
if result.validUntilMonoTime < result.observedMonoTime:
return None
return result
@@ -140,21 +136,28 @@ def decode_safety(raw: bytes) -> SafetyState | None:
return None
def _read_intent(value) -> IntentState | None:
try:
result = IntentState(_bounded_id(str(value.producerSessionId)), int(value.sequence),
int(value.carStateLogMonoTime), int(value.observedMonoTime), int(value.validUntilMonoTime),
bool(value.allowedLatch), bool(value.pauseLateral), bool(value.pauseLongitudinal),
bool(value.settingsQualified), bool(value.lateralArmed), bool(value.optionalSetRelease))
if result.validUntilMonoTime < result.observedMonoTime:
return None
return result
except (KeyError, ValueError, OverflowError, RuntimeError, capnp.KjException):
return None
@_memoized
def decode_safety(raw: bytes) -> SafetyState | None:
return _payload(raw, custom.AolAxisState.SafetyWire, SAFETY_KIND, _read_safety,
(WIRE_VERSION, SAFETY_INVENTORY_VERSION))
@_memoized
def decode_intent(raw: bytes) -> IntentState | None:
value = _payload(raw, custom.AolAxisState.IntentWire, INTENT_KIND)
if value is None:
return None
try:
result = IntentState(_bounded_id(str(value.get('producerSessionId', ''))), int(value['sequence']),
int(value['carStateLogMonoTime']), int(value['observedMonoTime']), int(value['validUntilMonoTime']),
bool(value['allowedLatch']), bool(value['pauseLateral']), bool(value['pauseLongitudinal']),
bool(value['settingsQualified']), bool(value.get('lateralArmed', False)), bool(value.get('optionalSetRelease', False)))
if result.validUntilMonoTime < result.observedMonoTime:
return None
return result
except (KeyError, ValueError, OverflowError, RuntimeError, capnp.KjException):
return None
return _payload(raw, custom.AolAxisState.IntentWire, INTENT_KIND, _read_intent)
def encode_intent(value: IntentState) -> bytes:
-4
View File
@@ -272,10 +272,6 @@ def create_intent(CP, settings):
from openpilot.starpilot.car.hyundai.forte_intent import ForteCardIntent
return ForteCardIntent(CP, settings)
if qualified_ioniq6(CP):
from openpilot.starpilot.car.hyundai.ioniq6_intent import Ioniq6CardIntent
return Ioniq6CardIntent(settings)
from openpilot.starpilot.aol.intent import AolCardIntent
return AolCardIntent(settings, explicit_latch=policy_for(CP).explicit_latch)
@@ -1,25 +0,0 @@
"""Ioniq 6 AOL desire: a full engagement also arms LKAS-managed AOL."""
from openpilot.starpilot.aol.intent import AolCardIntent, AOL_TOGGLE
class Ioniq6CardIntent(AolCardIntent):
def __init__(self, settings):
super().__init__(settings, explicit_latch=True)
self._engaged_previous = False
def update(self, CS, *, consumed_buttons=frozenset(), fault_active=None, now_ns=0, native_rejection_ns=0,
standard_enabled=False):
super().update(CS, consumed_buttons=consumed_buttons, fault_active=fault_active, now_ns=now_ns,
native_rejection_ns=native_rejection_ns, standard_enabled=standard_enabled)
# A stale carControl can drop standard_enabled for a frame while cruise stays
# engaged; requiring both to fall keeps that from reading as a new engagement.
engaged = bool(standard_enabled or CS.cruiseState.enabled)
engagement_started = engaged and not self._engaged_previous
self._engaged_previous = engaged
if (engagement_started and self.settings.enabled and self.lkas_action == AOL_TOGGLE and
CS.canValid and not CS.canTimeout and not CS.accFaulted and not self._fault_inhibit):
# Full engagement means LAT and LONG: arm AOL so brake withdraws only LONG,
# and LKAS can then drop the remaining LAT.
self.allowed_latch = True
self.pause_lateral = False
self._last_latch_edge_ns = now_ns
@@ -1,174 +0,0 @@
"""Ioniq 6 AOL: full engagement arms AOL, and brake's LONG withdrawal keeps LAT intent."""
from types import SimpleNamespace
from typing import Any
import pytest
from opendbc.car import Bus, structs
from openpilot.cereal import messaging
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.car.tests.test_hyundai_aol import candidate
from openpilot.starpilot.aol.intent import AOL_TOGGLE, AolSettings
from openpilot.starpilot.aol.transport_pause import TransportPauseFeedback, _healthy_transport_native, independent_lateral_health
from openpilot.starpilot.aol.wire import PandaSlot, SafetyState, encode_safety
from openpilot.starpilot.car.hyundai.aol import create_intent
from openpilot.starpilot.car.hyundai.ioniq6_intent import Ioniq6CardIntent
ButtonType = structs.CarState.ButtonEvent.Type
NOW = 10_000_000_000
def ioniq6(alt=False):
_, cp = candidate(alt)
cp.safetyConfigs[0].safetyParam = 0x8895 if alt else 0x8815
return cp
def settings(lkas_action=AOL_TOGGLE, enabled=True):
return AolSettings(enabled, 0.0, lkas_action, 0, (0, 0, 0), (0, 0, 0))
class Drive:
def __init__(self, intent):
self.intent = intent
self.cs = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive, vEgo=20.0)
self.now = NOW
self.step()
def step(self, *, engaged=None, cruise=None, buttons=(), standard=None):
if cruise is not None:
self.cs.cruiseState.enabled = cruise
self.cs.buttonEvents = [structs.CarState.ButtonEvent(type=b, pressed=p) for b, p in buttons]
self.now += 10_000_000
enabled = engaged if standard is None else standard
self.intent.update(self.cs, now_ns=self.now, standard_enabled=bool(enabled))
return self.intent.output(self.cs)[:2]
def engage(self):
return self.step(engaged=True, cruise=True)
def brake(self):
return self.step(engaged=False, cruise=False)
def lkas(self, engaged):
self.step(engaged=engaged, buttons=((ButtonType.lkas, True),))
return self.step(engaged=engaged, buttons=((ButtonType.lkas, False),))
@pytest.mark.parametrize('alt', [False, True])
def test_qualified_ioniq6_uses_engagement_intent(alt):
assert isinstance(create_intent(ioniq6(alt), settings()), Ioniq6CardIntent)
def test_full_engagement_arms_aol_so_brake_only_drops_long():
drive = Drive(create_intent(ioniq6(), settings()))
assert drive.step(engaged=False) == (False, False)
assert drive.engage() == (True, False)
assert drive.brake() == (True, False)
# LKAS then drops the remaining AOL lateral.
assert drive.lkas(engaged=False) == (False, True)
# A fresh SET engagement is LAT and LONG again.
assert drive.engage() == (True, False)
def test_lkas_in_full_op_pauses_lateral_and_stale_control_does_not_rearm():
drive = Drive(create_intent(ioniq6(), settings()))
drive.engage()
assert drive.lkas(engaged=True) == (False, True)
# carControl briefly stale while cruise stays engaged is not a new engagement.
assert drive.step(standard=False) == (False, True)
assert drive.step(standard=True) == (False, True)
# Brake keeps the driver's lateral pause; LKAS restores AOL.
assert drive.brake() == (False, True)
assert drive.lkas(engaged=False) == (True, False)
@pytest.mark.parametrize('config', ['lkas_not_aol', 'aol_disabled', 'acc_fault', 'can_invalid'])
def test_engagement_does_not_arm_without_lkas_aol(config):
intent = create_intent(ioniq6(), settings(lkas_action=0 if config == 'lkas_not_aol' else AOL_TOGGLE,
enabled=config != 'aol_disabled'))
drive = Drive(intent)
drive.cs.accFaulted = config == 'acc_fault'
drive.cs.canValid = config != 'can_invalid'
drive.engage()
assert not intent.allowed_latch
class Peers:
def __init__(self, messages):
self.messages = {m.which(): m for m in messages}
self.logMonoTime = {n: int(m.logMonoTime) for n, m in self.messages.items()}
self.seen = self.valid = self.alive = self.updated = dict.fromkeys(self.messages, True)
def __getitem__(self, name):
return getattr(self.messages[name], name)
def transport_fixture(*, long_requested=True):
cp = ioniq6(alt=True)
config = cp.safetyConfigs[0]
slots = (PandaSlot(0, 'unit-ioniq', config.safetyModel.raw, config.safetyParam, cp.alternativeExperience, False),)
# Brake revoked LONG at the Panda; the host has not yet withdrawn its LONG request.
native = SafetyState(1, True, NOW - 20_000_000, NOW + 180_000_000, config.safetyModel.raw, config.safetyParam,
True, False, True, long_requested, 'unit-ioniq', 'unit-session',
sourcePandaStatesMonoTime=NOW - 21_000_000, pandaInventory=slots)
safety = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=native.observedMonoTime)
safety.aolSafetyWire = encode_safety(native)
pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=NOW)
p = pandas.pandaStates[0]
p.safetyModel, p.safetyParam, p.alternativeExperience = config.safetyModel, config.safetyParam, cp.alternativeExperience
axis = messaging.new_message('aolAxisState', valid=True, logMonoTime=NOW - 1_000_000)
a = axis.aolAxisState
a.sessionId = a.faultSessionId = 'unit-session'
a.qualified, a.sequence, a.faultReason = True, 2, 'none'
a.observedMonoTime, a.validUntilMonoTime = axis.logMonoTime, axis.logMonoTime + 30_000_000
a.sourceCarStateMonoTime = axis.logMonoTime - 1000
a.desiredLateral = a.lateralActive = a.nativeAcknowledged = True
events = messaging.new_message('onroadEvents', 1, valid=True, logMonoTime=NOW - 1_000_000)
events.onroadEvents[0].name = 'pedalPressed'
events.onroadEvents[0].userDisable = True
sm = Peers([safety.as_reader(), pandas.as_reader(), axis.as_reader(), events.as_reader()])
cs = structs.CarState(canValid=True, brakePressed=True, vEgo=13.5, gearShifter=structs.CarState.GearShifter.drive)
feedback = TransportPauseFeedback(cp)
# An already observed healthy, acknowledged LAT+LONG baseline one frame earlier.
feedback.session = native.axisSessionId
feedback.healthy_feedback_ns = NOW - 10_000_000
feedback.healthy_native_ns = native.observedMonoTime
feedback.healthy_inventory = (native.pandaSerial, tuple(s.hardwareSerial for s in slots))
return cp, cs, sm, native, feedback
def test_brake_long_revoke_window_retains_lateral_intent():
cp, cs, sm, native, feedback = transport_fixture()
# Recorded 2026-10-14 drive, 586.87 s: this window used to hard-reset the AOL latch.
assert _healthy_transport_native(sm, cp, cs, 'unit-session', NOW) is None
assert independent_lateral_health(sm, cp, cs, 'unit-session', NOW) == native
assert not feedback.observe(sm, cp, cs, NOW, latched=True, brake_source_ns=NOW - 1000)
assert feedback.honda_brake_ns == NOW - 1000
@pytest.mark.parametrize('mutation', ['no_source', 'no_brake', 'stale_source', 'no_baseline'])
def test_brake_window_without_its_conditions_still_fails_closed(mutation):
cp, cs, sm, _, feedback = transport_fixture()
source = {'no_source': 0, 'stale_source': NOW - 30_000_001}.get(mutation, NOW - 1000)
cs.brakePressed = mutation != 'no_brake'
if mutation == 'no_baseline':
feedback.healthy_feedback_ns = 0
assert feedback.observe(sm, cp, cs, NOW, latched=True, brake_source_ns=source)
assert feedback.honda_brake_ns == 0
def test_card_ioniq6_brake_source_reads_tcs():
cp = ioniq6()
cs = structs.CarState(canValid=True, brakePressed=True)
card: Any = Car.__new__(Car)
card.CP = cp
parser = SimpleNamespace(vl={'TCS': {'DriverBraking': 1}}, ts_nanos={'TCS': {'DriverBraking': NOW + 9_000_000_000}})
card.CI = SimpleNamespace(can_parsers={Bus.pt: parser})
card._aol_can_observation = (cs, NOW - 1000, NOW + 9_000_000_000, NOW + 9_000_001_000)
assert card.aol_honda_brake_source(cs, NOW) == NOW - 1000
parser.ts_nanos['TCS']['DriverBraking'] -= 30_000_001
assert card.aol_honda_brake_source(cs, NOW) == 0
parser.ts_nanos['TCS']['DriverBraking'] += 30_000_001
parser.vl['TCS']['DriverBraking'] = 0
assert card.aol_honda_brake_source(cs, NOW) == 0
+21 -9
View File
@@ -5,11 +5,14 @@ import math
import time
from openpilot.cereal.services import SERVICE_LIST
from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.curve_speed.runtime import Frame, INPUT_MAX_AGE_NS, MODEL_MAX_AGE_NS, Runtime
from openpilot.starpilot.curve_speed.target import CurveProfile
from openpilot.starpilot.lead_tracking import LeadDetector
from openpilot.starpilot.lead_tracking import FILTER_THRESHOLD, FILTER_TIME_S, LeadDetector
CLOCK_PAIR_MAX_SKEW_NS = 5_000_000
# Steps from a reset before the detector's filter can first report a present lead.
LEAD_WARMUP_FRAMES = math.ceil(math.log(1 - FILTER_THRESHOLD) / math.log(1 - DT_MDL / (FILTER_TIME_S + DT_MDL)))
def clock_pair_ns() -> tuple[int, int] | None:
@@ -67,12 +70,14 @@ class CurveHost:
self.boot_offset_ns: int | None = None
self.source_floor_ns = 0
self.leads = LeadDetector()
self.lead_samples = 0
self.lead_drive_id = 0
self.previous_long_active = False
self.previous_active_headway: float | None = None
def _reset_leads(self) -> None:
self.leads.reset()
self.lead_samples = 0
self.previous_long_active = False
self.previous_active_headway = None
@@ -101,22 +106,22 @@ class CurveHost:
required = ('modelV2', 'carState', 'carControl', 'controlsState', 'selfdriveState', 'radarState')
try:
camera_eof = sm['modelV2'].timestampEof
camera_current = (type(camera_eof) is int and 0 < camera_eof <= pair[1] and
pair[1] - camera_eof <= MODEL_MAX_AGE_NS)
camera_age = now_ns + offset - camera_eof if type(camera_eof) is int and camera_eof > 0 else -1
camera_current = 0 <= camera_age <= MODEL_MAX_AGE_NS
except (KeyError, AttributeError, TypeError, ValueError, OverflowError):
camera_current = False
# A newly published model envelope cannot renew the age of its camera
# input. EOF uses BOOTTIME; only the bracketed clock pair may compare it.
# Offline replay intentionally keeps its separate recorded timeline.
if (not camera_current or not 0 <= pair[0] - now_ns <= INPUT_MAX_AGE_NS or
not all(_fresh(sm, name, pair[0]) for name in required)):
# Ages are judged at now_ns: this runs after the MPC solve, which alone
# exceeds the 20 ms lifetime of a 100 Hz input.
if not camera_current or not 0 <= pair[0] - now_ns <= INPUT_MAX_AGE_NS:
frame = replace(frame, inputs_fresh=False)
if rearmed or any(type(sm.logMonoTime.get(name, 0)) is not int or sm.logMonoTime[name] <= self.source_floor_ns
for name in required):
frame = replace(frame, inputs_fresh=False)
if not frame.inputs_fresh or frame.profile is None:
self._reset_leads()
else:
# A skipped stale frame needs no reset: the detector resets itself on longer gaps.
if frame.inputs_fresh and frame.profile is not None:
try:
# Tracking precedes headway reset: the first inactive tick retains the prior headway,
# then uses zero. Active control requires this cycle's MPC headway.
@@ -131,9 +136,16 @@ class CurveHost:
observed_mono_s=frame.model_ns / 1e9, now_mono_s=now_ns / 1e9,
radar_fresh=_fresh(sm, 'radarState', now_ns), model_fresh=_fresh(sm, 'modelV2', now_ns),
car_fresh=_fresh(sm, 'carState', now_ns), headway_fresh=headway_fresh)
if self.leads.last_observed_mono_s is None:
self.lead_samples = 0
elif observed.tracked is not None and not sm['carState'].standstill:
self.lead_samples += 1
tracked = observed.tracked
following = (observed.following if frame.long_active else
False if observed.tracked is not None else None)
frame = replace(frame, tracking_lead=observed.tracked, following_lead=following)
if self.lead_samples < LEAD_WARMUP_FRAMES and not tracked and observed.raw_present:
tracked = following = None
frame = replace(frame, tracking_lead=tracked, following_lead=following)
self.previous_long_active = frame.long_active if observed.tracked is not None else False
self.previous_active_headway = (float(detector_headway) if detector_headway is not None and frame.long_active and headway_fresh and
observed.tracked is not None else None)
+22 -8
View File
@@ -76,6 +76,7 @@ class Runtime:
self.filter = TargetFilter()
self.last_ns = 0
self.last_model_ns = 0
self.last_valid_ns = 0
self.drive_id = 0
self.was_controlling = False
self.was_long_active = False
@@ -94,6 +95,7 @@ class Runtime:
self.pending_ns = 0
self.pending_target: float | None = None
self.pending_cap = False
self.pending_hold = False
self.pending_cruise = 0.0
self.pending_ego = 0.0
self.glow = False
@@ -110,6 +112,7 @@ class Runtime:
self.filter.reset()
self.last_ns = 0
self.last_model_ns = 0
self.last_valid_ns = 0
self.drive_id = drive_id
self.was_controlling = False
self.was_long_active = False
@@ -127,6 +130,7 @@ class Runtime:
self.pending_ns = 0
self.pending_target = None
self.pending_cap = False
self.pending_hold = False
self.pending_cruise = 0.0
self.pending_ego = 0.0
self.glow = False
@@ -149,6 +153,8 @@ class Runtime:
self.glow = False
return False
self.confirmed_ns = frame_ns
if self.pending_hold:
return True
target = self.pending_target
selected = applied and self.pending_cap and target is not None
self.was_controlling = bool(selected and target < self.pending_cruise - 1.0 and
@@ -230,24 +236,32 @@ class Runtime:
self.pending_ns = now
self.pending_target = None
self.pending_cap = False
self.pending_hold = False
valid_profile = (frame.inputs_fresh and frame.profile is not None and
type(frame.model_ns) is int and 0 < frame.model_ns <= now and
now - frame.model_ns <= MODEL_MAX_AGE_NS and
frame.profile.observed_ns == frame.model_ns and
frame.model_ns > self.last_model_ns)
if not valid_profile:
self.filter.reset()
self.watch_until_ns = 0
self.last_training_ns = 0
self.nudged = False
self.override = False
self.was_controlling = False
self.glow = False
self.was_long_active = frame.long_active
self.was_gas = frame.gas_pressed
self.was_brake = frame.brake_pressed
# Isolated late 100 Hz inputs occur on device; hold control state without a ceiling.
self.pending_hold = (self.filter.target is not None and
now - self.last_valid_ns <= int(MAX_STEP_SECONDS * 1e9))
if self.pending_hold:
self.pending_target = self.filter.target
else:
self.filter.reset()
self.nudged = False
self.override = False
self.was_controlling = False
self.glow = False
self.was_long_active = frame.long_active
self.was_gas = frame.gas_pressed
self.was_brake = frame.brake_pressed
return self._result(None, None, 0.0, 'stale_or_invalid_evidence', False, now)
self.last_model_ns = frame.model_ns
self.last_valid_ns = now
if (not finite_number(frame.cruise_mps) or frame.cruise_mps <= 0 or
not finite_number(frame.ego_mps) or frame.ego_mps < 0 or
frame.ego_mps > 100.0 or not finite_number(frame.measured_curvature) or
+2 -1
View File
@@ -50,7 +50,8 @@ class StatusPublisher:
'documentValid': bool(runtime.document_valid),
'hasCandidate': result.candidate_mps is not None, 'candidateMps': float(result.candidate_mps or 0.0),
'hasCeiling': result.ceiling_mps is not None, 'ceilingMps': float(result.ceiling_mps or 0.0),
'applied': bool(planner.last_curve_ceiling_applied), 'controlling': bool(runtime.was_controlling),
'applied': bool(planner.last_curve_ceiling_applied),
'controlling': bool(runtime.was_controlling and planner.last_curve_ceiling_applied),
'training': bool(result.training), 'calibrationProgress': float(result.progress),
'comfortAccel': float(runtime.curve.average_comfort), 'bindingDistance': float(result.binding_distance_m),
'reason': result.reason if result.ceiling_mps is not None else result.learning_reason,
@@ -101,6 +101,50 @@ class TestCurveHost(unittest.TestCase):
self.assertFalse(seen[-1][1].following)
self.assertFalse(seen[-1][1].tracked)
def capture_frames(self, host):
frames = []
original = host.runtime.step
def runtime_capture(frame, **kwargs):
frames.append(frame)
return original(frame, **kwargs)
self.enterContext(patch.object(host.runtime, 'step', side_effect=runtime_capture))
return frames
def present_lead(self, d_rel=30.0):
radar = self.sm.messages['radarState'].leadOne
radar.present = True
radar.dRel = d_rel
def test_stale_frame_keeps_lead_consensus(self):
host = CurveHost(enabled=True, no_lead=True, replay=True)
frames = self.capture_frames(host)
self.present_lead()
for n in range(1, 17):
stamp = NOW + n * 50_000_000
self.sm.logMonoTime = dict.fromkeys(self.sm.messages, stamp)
host.sample(self.sm, self.cp, stamp, follow_time_s=1.45)
self.assertTrue(frames[-1].following_lead)
stamp += 45_000_000
self.sm.logMonoTime = dict.fromkeys(self.sm.messages, stamp)
self.sm.logMonoTime['carState'] = stamp - 30_000_000
host.sample(self.sm, self.cp, stamp, follow_time_s=1.45)
stamp += 45_000_000
self.sm.logMonoTime = dict.fromkeys(self.sm.messages, stamp)
host.sample(self.sm, self.cp, stamp, follow_time_s=1.45)
self.assertTrue(frames[-1].tracking_lead)
self.assertTrue(frames[-1].following_lead)
def test_present_lead_is_unknown_until_detector_warms_up(self):
host = CurveHost(enabled=True, no_lead=True, replay=True)
frames = self.capture_frames(host)
self.present_lead()
for n in range(1, 21):
stamp = NOW + n * 50_000_000
self.sm.logMonoTime = dict.fromkeys(self.sm.messages, stamp)
self.assertIsNone(host.sample(self.sm, self.cp, stamp, follow_time_s=1.45).ceiling_mps)
self.assertNotIn(False, [frame.following_lead for frame in frames])
self.assertTrue(frames[-1].following_lead)
def test_actual_serialized_schema_projection(self):
events = {name: messaging.new_message(name) for name in self.sm.messages}
events['modelV2'].modelV2.orientationRate.z = [0.25] * 33
@@ -178,6 +222,30 @@ class TestCurveHost(unittest.TestCase):
self.assertFalse(result.training)
self.assertEqual(result.reason, 'stale_or_invalid_evidence')
def test_live_planner_latency_does_not_age_out_loop_inputs(self):
# Measured on device: inputs ~5 ms old at loop start, Curve clock sampled ~35 ms later.
offset = 2_000_000_000
for latency, input_age, camera_age, expected in (
(35_000_000, 5_000_000, 60_000_000, True),
(35_000_000, 21_000_000, 60_000_000, False),
(35_000_000, 5_000_000, 150_000_001, False),
(150_000_001, 5_000_000, 60_000_000, False),
):
with self.subTest(latency=latency, input_age=input_age, camera_age=camera_age):
tick = [NOW]
host = CurveHost(enabled=True, clock_pair=lambda: (tick[0] + latency, tick[0] + latency + offset))
result = None
for _ in range(4):
tick[0] += 50_000_000
self.sm.logMonoTime = dict.fromkeys(self.sm.messages, tick[0] - input_age)
self.sm.logMonoTime['modelV2'] = tick[0] - 3_000_000
self.sm.logMonoTime['radarState'] = tick[0] - 40_000_000
self.sm.messages['modelV2'].timestampEof = tick[0] + offset - camera_age
result = host.sample(self.sm, self.cp, tick[0])
self.assertEqual(result.ceiling_mps is not None, expected)
if not expected:
self.assertEqual(result.reason, 'stale_or_invalid_evidence')
if __name__ == '__main__':
unittest.main()
@@ -1,7 +1,9 @@
import unittest
from dataclasses import replace
from types import SimpleNamespace as NS
from openpilot.starpilot.curve_speed.runtime import DriverEvent, Frame, Runtime
from openpilot.starpilot.curve_speed.status import StatusPublisher, observation
from openpilot.starpilot.curve_speed.target import CurveProfile
@@ -84,6 +86,58 @@ class TestCurveRuntime(unittest.TestCase):
self.assertIsNotNone(rt.step(frame(4)).ceiling_mps)
self.assertIsNotNone(rt.step(frame(20)).ceiling_mps) # discontinuity resets filter, then fresh frame can rearm
def test_isolated_stale_frame_holds_target_without_ceiling(self):
rt = Runtime(enabled=True)
arm_selected(rt)
held = rt.filter.target
stale = frame(27, inputs_fresh=False)
self.assertIsNone(rt.step(stale).ceiling_mps)
rt.confirm_applied(stale.now_ns, applied=False)
self.assertTrue(rt.was_controlling)
self.assertTrue(rt.glow)
self.assertEqual(rt.filter.target, held)
resumed = rt.step(frame(28)).ceiling_mps
assert resumed is not None
self.assertLessEqual(resumed, held)
def test_stale_hold_publishes_valid_glowing_status(self):
rt = Runtime(enabled=True)
arm_selected(rt)
stale = frame(27, inputs_fresh=False)
result = rt.step(stale)
rt.confirm_applied(stale.now_ns, applied=False)
planner = NS(last_curve_ceiling_applied=False, last_curve_ceiling_status='absent')
event = StatusPublisher().attach(None, NS(runtime=rt), result, planner, now_ns=stale.now_ns,
model_ns=frame(26).now_ns, persistence_status='idle')
status = observation(event.slcState, stale.now_ns)
assert status is not None
self.assertTrue(status.glow)
self.assertFalse(status.controlling)
def test_stale_hold_expires_after_max_step(self):
rt = Runtime(enabled=True)
arm_selected(rt)
for n in range(27, 31):
item = frame(n, inputs_fresh=False)
rt.step(item)
rt.confirm_applied(item.now_ns, applied=False)
self.assertIsNotNone(rt.filter.target)
item = frame(31, inputs_fresh=False)
rt.step(item)
rt.confirm_applied(item.now_ns, applied=False)
self.assertIsNone(rt.filter.target)
self.assertFalse(rt.glow)
self.assertFalse(rt.was_controlling)
def test_gas_override_during_stale_hold_still_latches(self):
rt = Runtime(enabled=True)
arm_selected(rt)
stale = frame(27, inputs_fresh=False, gas_pressed=True)
rt.step(stale)
rt.confirm_applied(stale.now_ns, applied=False)
self.assertIsNone(rt.step(frame(28, gas_pressed=True)).ceiling_mps)
self.assertTrue(rt.override)
def test_invalid_document_never_learns_or_persists(self):
rt = Runtime({'version': 1, 'buckets': {'nan': {'average': 2.0, 'count': 1}}}, enabled=True)
self.assertFalse(rt.document_valid)
@@ -66,9 +66,10 @@ class AndroidAutoSetup:
return {
'enabled': self.enabled(), 'bluetoothEnabled': self.bluetooth_enabled(), 'parked': self.parked(),
'installReady': self.install_ready(), 'serviceReady': self.service_ready(),
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error') if key in ident},
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error', 'app_version') if key in ident},
'import': self.job.status(),
'download': apkpure.download_status(),
'downloadPending': apkpure.lookup_pending(),
'maxUploadBytes': apk_identity.MAX_FILE_BYTES,
'wiredAvailable': False,
'steps': [
@@ -133,7 +134,8 @@ class AndroidAutoSetup:
out.flush()
os.fsync(out.fileno())
self._require_session(session)
self.job.start(path=path, enabled=lambda: self.session_valid(session))
# APKPure's XAPK carries no version, so name it after the release we offered when the size matches.
self.job.start(path=path, enabled=lambda: self.session_valid(session), app_version=apkpure.version_for_size(length))
started = True
return self.status(session)
finally:
+2 -2
View File
@@ -57,7 +57,7 @@ def preview_layout(self, payload, projection_layout_owner, layout_owner, layout_
self.json(401, {'error': 'Sign in to Galaxy'})
return
if (type(payload) is not dict or set(payload) not in ({'document', 'profile', 'scene'}, {'document', 'profile', 'scene', 'art'}) or
('art' in payload and payload['art'] is not True) or
('art' in payload and not (payload['art'] is True or payload['art'] == 'backdrop')) or
type(payload['profile']) is not str or payload['profile'] not in (PREVIEW_PROFILES | {'projection'}) or
type(payload['scene']) is not str or payload['scene'] not in PREVIEW_SCENES):
self.json(400, {'error': 'Invalid layout preview request'})
@@ -82,7 +82,7 @@ def preview_layout(self, payload, projection_layout_owner, layout_owner, layout_
self.json(403, {'error': 'Turn off the vehicle to preview this layout'})
return
image = request_preview({'document': document, 'profile': payload['profile'], 'scene': payload['scene'],
**({'art': True} if payload.get('art') is True else {})},
**({'art': payload['art']} if 'art' in payload else {})},
layout_preview_socket)
if self.settings_session() != identity:
self.json(401, {'error': 'Sign in to Galaxy'})
@@ -11,8 +11,9 @@ from openpilot.starpilot.system.android_auto.display_profile import (
SCREEN_PATH, MAX_BYTES as SCREEN_MAX_BYTES, record_screen, validate_screen,
)
from openpilot.starpilot.system.android_auto.projection_layout import (
DOCUMENT_KEY, MAX_BYTES, ProjectionLayoutSource, decode_layout, default_layout, layout_metadata, validate_layout,
DOCUMENT_KEY, MAX_BYTES, ProjectionLayoutSource, comma_display, decode_layout, default_layout, layout_metadata, validate_layout,
)
from openpilot.starpilot.ui.appearance_preferences import onroad_appearance
from openpilot.starpilot.ui.onroad_customization import read_customization
@@ -64,6 +65,9 @@ class ProjectionLayoutOwner:
raw, readable = read_saved(self.source, DOCUMENT_KEY, MAX_BYTES)
defaults = default_layout(screen) if screen is not None else None
document = defaults
if readable and raw is None and screen is not None:
# A first AA layout starts from the comma screen's display choices.
document = default_layout(screen, comma_display(onroad_appearance(self.params)))
valid = False
if readable and raw is not None and screen is not None:
try:
+4 -3
View File
@@ -57,6 +57,7 @@ from openpilot.starpilot.audio.downloads import SoundDownloads, SoundDownloadErr
WEB = Path(__file__).resolve().parent / 'web'
INSTANCE = secrets.token_hex(8)
def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashes=None, software=None, maps=None, models=None, settings=None,
plots=None, map_operations=None, recordings=None, recording_media=None, segment_summary=None,
sentry_events=None, notifications=None, flm_operations=None, bluetooth=None, vehicle_selection=None,
@@ -672,7 +673,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
f'<rect width="100%" height="100%" fill="white"/><g fill="black">{cells}</g></svg>')
self.respond(200, svg.encode(), 'image/svg+xml')
elif path == '/data/runtime.json':
self.json(200, {'schemaVersion': 1, 'monitor': 'local'})
self.json(200, {'schemaVersion': 1, 'monitor': 'local', 'instance': INSTANCE})
elif path in ('/api/local-access', '/api/tmux/live', '/api/troubleshoot'):
if not self.require_session():
return
@@ -813,7 +814,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(503, {'error': 'Software status is unavailable'})
else:
if self.settings_session() == identity:
self.json(200, result)
self.json(200, {**result, 'instance': INSTANCE})
else:
if self.require_session():
self.json(401, {'error': 'Sign in to Galaxy'})
@@ -2030,7 +2031,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(403, {'error': 'Park before pairing the car'})
return
if not aa_setup_owner().enabled():
self.json(403, {'error': 'Enable experimental Android Auto first'})
self.json(403, {'error': 'Enable Android Auto first'})
return
try:
setup_state = aa_setup_owner().status(identity)
@@ -38,7 +38,7 @@ class FakeImport:
self.running = False
def busy(self): return self.running
def status(self): return {'state': 'running' if self.running else 'idle'}
def start(self, *, path, enabled):
def start(self, *, path, enabled, app_version=None):
self.calls.append((path.read_bytes(), enabled))
self.running = True
@@ -72,8 +72,9 @@ class FakeImport:
self.running = False
def busy(self): return self.running
def status(self): return {'state': 'running' if self.running else 'idle'}
def start(self, *, path, enabled):
def start(self, *, path, enabled, app_version=None):
self.started.append((path, path.read_bytes(), enabled))
self.app_version = app_version
self.running = True
@@ -93,6 +94,8 @@ def test_status_explains_phone_role_and_hides_key(tmp_path):
service, _ = setup(tmp_path, state)
result = service.status(session)
assert result['identity'] == {'installed': False, 'message': 'No identity'}
service.identity_status = lambda: {'installed': True, 'app_version': '16.2.1-release', 'source': 'upload'}
assert service.status(session)['identity'] == {'installed': True, 'app_version': '16.2.1-release'}
assert result['bluetoothEnabled'] is True
assert 'phone' in result['steps'][0] and 'Wi-Fi access point' in result['steps'][3]
assert result['wiredAvailable'] is False and result['maxUploadBytes'] == MAX_FILE_BYTES
@@ -115,6 +118,17 @@ def test_upload_is_bounded_private_and_session_bound(tmp_path, parked, enabled):
service.upload(('galaxy', '1'), io.BytesIO(b'abc'), 3)
def test_upload_matching_offered_release_size_is_labelled_with_its_version(tmp_path, monkeypatch):
state: SetupState = {'parked': True, 'enabled': True, 'session': ('galaxy', '1')}
session = state['session']
assert session is not None
monkeypatch.setattr(apkpure, '_cache', {'version': '17.7.663654-release', 'url': '', 'size': 14, 'expires': 0})
for data, expected in ((b'not-a-real-apk', '17.7.663654-release'), (b'other-size', None)):
service, job = setup(tmp_path, state)
service.upload(session, io.BytesIO(data), len(data))
assert job.app_version == expected
def test_invalid_or_truncated_upload_never_starts_job(tmp_path):
state: SetupState = {'parked': True, 'enabled': True, 'session': ('galaxy', '1')}
session = state['session']
@@ -73,6 +73,23 @@ class TestProjectionLayoutOwner(unittest.TestCase):
self.assertEqual(json.loads(self.source.path.read_bytes()), payload['document'])
self.assertFalse((self.root / 'OnroadCustomizations').exists())
def test_first_layout_starts_from_comma_display_choices(self):
self.screen()
for key, raw in {'BlindSpotMetrics': b'0', 'SignalMetrics': b'1', 'HidePersonalityAlerts': b'1', 'ShowBrakeStatus': b'1',
'HideLeadMarker': b'0', 'LeadInfo': b'1', 'LeadInfoMode': b'1'}.items():
(self.root / key).write_bytes(raw)
value = self.owner.snapshot()
seeded = {key: value['document'][key] for key in ('blindSpotBorder', 'signalBorder', 'hidePersonalityAlerts',
'wheelPedalFeedback', 'leadIndicator', 'leadInfo', 'blindSpotCamera')}
self.assertEqual(seeded, {'blindSpotBorder': False, 'signalBorder': True, 'hidePersonalityAlerts': True,
'wheelPedalFeedback': True, 'leadIndicator': True, 'leadInfo': 'Distance', 'blindSpotCamera': False})
self.assertFalse(value['defaults']['signalBorder'], 'reset still returns to the plain defaults')
saved = self.owner.save(self.payload(), session_valid=lambda: True)
# Once saved, the AA screen keeps its own choices whatever the comma changes.
(self.root / 'SignalMetrics').write_bytes(b'0')
self.assertTrue(self.owner.snapshot()['document']['signalBorder'])
self.assertEqual(self.owner.snapshot()['document'], saved['document'])
def test_disabled_retains_layout_but_unavailable(self):
self.screen()
self.owner.save(self.payload(), session_valid=lambda: True)
@@ -0,0 +1,94 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const { execFileSync } = require('node:child_process')
const { readFileSync, mkdirSync } = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const web = path.resolve(__dirname, '../../web')
const fixtures = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', `
import json
from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport
doc=default_document()
for layout in doc['layouts'].values():
for widget in layout.values(): widget['enabled']=True
native=dict(document=doc,defaults=doc,metadata=customization_metadata(),revision='scenes',editable=True,valid=True,activeProfile='large',supportedProfiles=['large','compact'])
layout=default_layout_for_viewport((2880,1080))
for widget in layout['widgets'].values(): widget['enabled']=True
projection=dict(version=1,document=layout,defaults=layout,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision='aa-scenes',editable=True,valid=True,colors=dict(palette=doc['palette'],widgetColors=doc['widgetColors']['large'],roadColors={}))
print(json.dumps(dict(native=native,projection=projection)))
`], { encoding: 'utf8' }))
;(async () => {
const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) })
const output = process.env.SCENE_SCREENSHOTS
const nativeArt = process.env.NATIVE_SCENE_ART
if (output) mkdirSync(output, { recursive: true })
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } })
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('http://scenes.test/**', async route => {
const pathname = new URL(route.request().url()).pathname
if (pathname === '/api/ui/layout/preview') {
if (!nativeArt) return route.fulfill({ status: 503, json: { error: 'Scene sketch test' } })
const { profile, scene, art } = route.request().postDataJSON()
return route.fulfill({ contentType: 'image/png', body: readFileSync(path.join(nativeArt, `${profile}-${scene}-${art === true ? 'art' : 'backdrop'}.png`)) })
}
if (pathname.startsWith('/api/')) return route.fulfill({ json: pathname === '/api/android-auto/layout' ? fixtures.projection : fixtures.native })
if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: `
<meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/css/galaxy.css"><link rel="stylesheet" href="/css/settings.css">
<main class="gx-content"><div id="app"></div></main><script type="module">
import {createApp} from '/vendor/vue/vue.esm-browser.js'; import {OnroadLayoutPage} from '/js/onroad-layout.js';
window.editor=createApp(OnroadLayoutPage,{mode:'local',projection:location.search.includes('projection'),unauthorized(){}}).mount('#app');</script>` })
return route.fulfill({ body: readFileSync(path.join(web, pathname)), contentType: pathname.endsWith('.js') ? 'text/javascript' : pathname.endsWith('.css') ? 'text/css' : 'application/octet-stream' })
})
for (const target of ['large', 'compact', 'projection']) {
await page.goto('http://scenes.test/' + (target === 'projection' ? '?projection' : ''))
await page.locator('.gx-layout__layer').first().waitFor()
if (target === 'compact') await page.getByRole('button', { name: /^Small/ }).click()
const scenes = await page.evaluate(() => window.editor.availableScenes)
const original = await page.evaluate(() => JSON.stringify(window.editor.state.draft))
for (const scene of scenes) {
// Choose scenes through the real accessible dropdown.
await page.getByRole('combobox', { name: 'Preview scene', exact: true }).click()
await page.getByRole('option', { name: scene.label, exact: true }).click()
// After the first render, a scene switch keeps native artwork up instead of falling back to outlines.
if (nativeArt && scene !== scenes[0]) assert.equal(await page.locator('[data-preview-source="placeholder"]').count(), 0, `${target}/${scene.id}: no outlines while loading`)
if (nativeArt) await page.waitForFunction(() => window.editor.state.backdrop.scene === window.editor.state.scene &&
window.editor.state.backdrop.status === 'ready' && window.editor.state.art.scene === window.editor.state.scene && window.editor.state.art.status === 'ready')
const state = await page.evaluate(() => ({ scene: window.editor.state.scene, border: window.editor.sceneBorder,
visible: window.editor.renderWidgets.map(widget => widget.id), draft: JSON.stringify(window.editor.state.draft),
general: window.editor.layerWidgets.map(widget => widget.id), hidden: window.editor.layerWidgets.filter(widget => window.editor.sceneHidden(widget.id)).map(widget => widget.id) }))
assert.equal(state.scene, scene.id)
assert.equal(state.draft, original, `${target}/${scene.id}: scene changes never edit the saved layout`)
if (nativeArt) assert.equal(await page.locator('[data-preview-source="device-backdrop"]').count(), 1)
else assert.equal(await page.locator('[data-preview-source="scene-border"]').getAttribute('stroke'), state.border)
if (scene.id === 'general') assert.deepEqual([...state.visible].sort(), [...state.general].sort())
for (const id of state.hidden) {
assert(!state.visible.includes(id), `${target}/${scene.id}/${id}: hidden widgets have no hit box`)
const name = await page.evaluate(id => window.editor.profile.widgets[id].label, id)
assert.equal(await page.getByRole('button', { name: `${name} is hidden in this scene. Choose General edit to arrange it.`, exact: true }).isDisabled(), true)
}
if (target !== 'large') assert.equal(state.visible.includes('stopped_timer'), ['general', 'stopped'].includes(scene.id))
if (scene.id === 'slc_pending') {
assert.equal(state.visible.includes('speed_limit_actions'), true)
const actions = page.locator('.gx-layout__widget[aria-label^="Speed limit actions"]')
assert.equal(await actions.locator(`[data-preview-source="${nativeArt ? 'runtime' : 'placeholder'}"]`).count(), 1)
} else if (scene.id !== 'general') {
assert.equal(await page.locator('.gx-layout-widget-art').getByText('Apply 54', { exact: true }).count(), 0)
}
if (output) await page.locator('svg.gx-layout__preview').screenshot({ path: path.join(output, `${target}-${scene.id}.png`) })
}
await page.evaluate(() => { window.editor.state.scene = 'general'; window.editor.togglePreviewVisibility('driver_monitor'); window.editor.state.scene = 'engaged' })
assert.equal(await page.evaluate(() => window.editor.state.hiddenPreviewWidgets[window.editor.state.profile].driver_monitor), true)
assert.equal(await page.evaluate(() => window.editor.sceneHidden('driver_monitor') || !window.editor.previewHidden('driver_monitor')), true)
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
}
}
assert.deepEqual(errors, [])
console.log('All three layout scene menus, border colors, automatic eyes, timer/CEM/actions, scene isolation, draft preservation and mobile sizing passed.')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exit(1) })
@@ -305,14 +305,20 @@ function visit(node) {
visit(tree)
assert(!tags.includes('template'), 'setup cannot live inside an inert template element')
assert(tags.includes('input') && tags.includes('button'), 'setup renders package selection and actions')
// Once installed, Manage package shows status and removal only; setup needs removal first.
// Once installed, Update / Install newer version opens the installer itself; no removal first.
const managed = page({ ...setup, identity: { ...setup.identity, installed: true } })
managed.installOpen = true
managed.openInstall()
assert.equal(managed.installState, 'choose')
const managedTags = []
const collect = (node) => { if (!node || typeof node !== 'object') return; if (typeof node.type === 'string') managedTags.push(node.type); if (Array.isArray(node.children)) node.children.forEach(collect) }
collect(render(managed, []))
assert(!managedTags.includes('input'), 'manage view offers no setup inputs')
assert(managedTags.includes('dl'), 'manage view shows status')
assert(managedTags.includes('input'), 'an installed package can be replaced by a newer one')
assert.match(AndroidAutoPage.template, /Install a Newer Version/)
assert.equal(page({ ...setup, identity: { ...setup.identity, installed: true, app_version: '17.8.163804-release.daily' } }).installedVersion, '17.8.163804')
assert.equal(managed.installedVersion, '', 'an unknown version shows nothing')
assert.match(AndroidAutoPage.template, /You have \{\{ installedVersion \}\}/)
assert.match(AndroidAutoPage.template, /Current version: \{\{ installedVersion \}\}/)
assert.doesNotMatch(AndroidAutoPage.template, /remove it first/)
for (const [field, reason] of [['installReady', /display|encoder/],
['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) {
const blocked = page({ ...setup, [field]: false })
@@ -403,7 +409,21 @@ connectingPage.runtime.state = 'streaming'
assert.equal(connectingPage.projecting, true)
assert.match(AndroidAutoPage.template, /Remove Android Auto support/)
assert.match(AndroidAutoPage.template, /Manage package/)
assert.doesNotMatch(AndroidAutoPage.template, /Manage package|>Turn On<|>Turn Off</)
assert.match(AndroidAutoPage.template, /Install newer version/)
assert.match(AndroidAutoPage.template, /switchRow\('Android Auto', setup\.enabled/)
assert.doesNotMatch(AndroidAutoPage.template, /Scan For Devices/)
assert.match(AndroidAutoPage.template, /href="#\/navigation\/maps"/)
// Returning drivers get a status card; the setup steps fold away until asked for.
const returning = page(setup)
assert.equal(returning.setupDone, false, 'no car chosen yet')
returning.selected = { address: "AA:BB:CC:DD:EE:FF", name: "Civic" }
assert.equal(returning.setupDone, true)
returning.pairing = { active: true }
assert.equal(returning.setupDone, false, 'pairing keeps the steps open')
returning.pairing = null
assert.equal(page({ ...setup, enabled: false }).setupDone, false, 'turned off shows the steps')
assert.match(AndroidAutoPage.template, /v-show="!setupDone \|\| showSteps"/)
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
try {
for (const [browser, mobile] of [
@@ -113,6 +113,15 @@ fallback.installOpen = true
const mirror = nodes(fallback).find(node => node.type === "a" && node.props?.href === APKMIRROR_URL)
assert(mirror, "falls back to APKMirror when the comma has no link")
assert.equal(mirror.props.target, "_blank")
assert(JSON.stringify(nodes(fallback).map(node => node.children)).includes("Download APK"), "APKMirror fallback shows the steps")
// While the comma is still trying APKPure, wait rather than jumping to APKMirror.
assert.deepEqual(page({ ...setup, download: null, downloadPending: true }).download, { direct: false, pending: true, url: "" })
assert.equal(page({ ...setup, downloadPending: true }).download.direct, true)
const waiting = page({ ...setup, download: null, downloadPending: true })
waiting.installOpen = true
const waitingNodes = nodes(waiting)
assert(!waitingNodes.some(node => node.type === "a" && node.props?.href === APKMIRROR_URL), "no APKMirror while still trying")
assert(waitingNodes.some(node => node.type === "button" && node.props && "disabled" in node.props && /Finding the newest/.test(JSON.stringify(node.children))))
// Upload progress uses the software-update bar so both share one palette.
const sending = page()
Object.assign(sending, { installOpen: true, installAttempted: true, uploadProgress: { loaded: 25, total: 100 } })
@@ -42,7 +42,7 @@ assert.equal(cancelled, 1)
let closed = 0
const editor = { busy: false, dirty: true, state: { drag: null, discard: null },
leave: OnroadLayoutPage.methods.leave, hideDevicePreview() {}, $emit(event) { if (event === "close") closed++ } }
leave: OnroadLayoutPage.methods.leave, $emit(event) { if (event === "close") closed++ } }
settings.state = { layoutOpen: true }; settings.busy = false
settings.$refs = { layoutEditor: { requestLeave(action) { OnroadLayoutPage.methods.requestLeave.call(editor, action) } } }
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
@@ -48,9 +48,10 @@ assert.ok(initializer.startsWith("let startupGeneration = 0"))
const state = { tools: [], loading: true, error: "", monitorMode: "sample" }
const authState = { status: "checking" }
let fail = true, checks = 0
const initialize = new Function("loadCatalog", "state", "authState", "auth", "document", `${initializer}; return initialize`)(
const tracked = []
const initialize = new Function("loadCatalog", "state", "authState", "auth", "document", "deviceRestart", `${initializer}; return initialize`)(
async () => { if (fail) throw new Error("offline"); return loaded }, state, authState,
{ async check() { checks++; authState.status = "authenticated" } }, { hidden: false },
{ async check() { checks++; authState.status = "authenticated" } }, { hidden: false }, { track: instance => tracked.push(instance) },
)
await initialize()
assert.match(state.error, /could not connect/)
@@ -61,10 +62,11 @@ assert.equal(state.error, "")
assert.equal(state.loading, false)
assert.equal(checks, 1)
assert.equal(authState.status, "authenticated")
assert.deepEqual(tracked, [loaded.instance])
const queued = []
const raced = new Function("loadCatalog", "state", "authState", "auth", "document", `${initializer}; return initialize`)(
() => new Promise(resolve => queued.push(resolve)), state, authState, { async check() {} }, { hidden: false },
const raced = new Function("loadCatalog", "state", "authState", "auth", "document", "deviceRestart", `${initializer}; return initialize`)(
() => new Promise(resolve => queued.push(resolve)), state, authState, { async check() {} }, { hidden: false }, { track() {} },
)
const old = raced(), current = raced()
queued[0]({ tools: [], mode: "sample" })
@@ -92,10 +94,11 @@ const sessionAuth = new LocalAuth({
},
})
const unmountBody = app.match(/ beforeUnmount\(\) \{([\s\S]*?)\n \},/)[1]
const harness = new Function("loadCatalog", "state", "authState", "auth", "document", "setTimeout", "clearTimeout",
const harness = new Function("loadCatalog", "state", "authState", "auth", "document", "setTimeout", "clearTimeout", "deviceRestart", "window",
`${initializer}; return { expire() { ${expiryBody} }, initialize, stop() { ${unmountBody} } }`)(
async () => loaded, transientState, transientAuth, sessionAuth, visibility,
(fn, delay) => { const id = ++timerId; timers.set(id, { fn, delay }); return id }, id => timers.delete(id),
{ track() {}, stop() {} }, { removeEventListener() {} },
)
const expiredFeed = new SnapshotFeed({ publish: () => {}, unauthorized: () => harness.expire(),
fetcher: async () => ({ ok: false, status: 401 }) })
@@ -7,11 +7,12 @@ assert.deepEqual(OnroadLayoutPage.computed.availableProfiles.call({state:{data:{
assert.deepEqual(OnroadLayoutPage.computed.availableProfiles.call({state:{data:{activeProfile:'large'}}}), ['large'])
assert.deepEqual(OnroadLayoutPage.computed.availableProfiles.call({state:{data:{}}}), [])
let selected = null
const vm = {busy:false, dirty:false, state:{drag:null}, hideDevicePreview(){}, $emit(event,target){selected = [event,target]}}
const vm = {busy:false, dirty:false, state:{drag:null}, $emit(event,target){selected = [event,target]}}
vm.leave = OnroadLayoutPage.methods.leave.bind(vm)
OnroadLayoutPage.methods.requestLeave.call(vm,'projection')
assert.deepEqual(selected,['target','projection'])
assert.ok(OnroadLayoutPage.template.includes('v-if="state.devicePreviewOpen"'))
assert.ok(OnroadLayoutPage.template.includes('data-preview-source="device-backdrop"'))
assert.ok(!OnroadLayoutPage.template.includes('devicePreviewOpen'))
const status = {version:1,available:true,parked:false,powered:true,discovering:false,errorCode:null,pairing:null,devices:[]}
const calls=[]
const feed = new BluetoothFeed({publish(){},fetcher:async(url,opts)=>{calls.push([url,opts]);return {ok:true,status:200,json:async()=>status}},later(){return 1},cancelTimer(){}})
@@ -0,0 +1,55 @@
import assert from "node:assert/strict"
import { DeviceRestartWatch } from "../web/js/device-restart.js"
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
function fixture({ paused = false } = {}) {
const timers = new Map(), requests = []
let nextTimer = 0, reloads = 0
const watch = new DeviceRestartWatch({ hidden: () => false, paused: () => paused,
request: () => new Promise((resolve, reject) => requests.push({ resolve, reject })),
later: fn => { const id = ++nextTimer; timers.set(id, fn); return id },
cancel: id => timers.delete(id), reload: () => reloads++ })
return { watch, timers, requests, tick() { const [id, fn] = timers.entries().next().value; timers.delete(id); fn() }, get reloads() { return reloads } }
}
const preview = fixture()
preview.watch.track(null)
assert.equal(preview.timers.size, 0)
const blip = fixture()
blip.watch.track("one")
blip.tick()
blip.requests[0].reject(new TypeError("offline"))
await flush()
blip.tick()
blip.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "one" })
await flush()
assert.equal(blip.reloads, 0)
assert.equal(blip.timers.size, 1)
const reboot = fixture()
reboot.watch.track("one")
reboot.tick()
reboot.requests[0].reject(new TypeError("offline"))
await flush()
reboot.tick()
reboot.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "two" })
await flush()
assert.equal(reboot.reloads, 1)
assert.equal(reboot.timers.size, 0)
const startup = fixture()
startup.watch.track("one")
startup.watch.track("two")
assert.equal(startup.reloads, 1)
const woken = fixture()
woken.watch.track("one")
woken.watch.wake()
assert.equal(woken.requests.length, 1)
assert.equal(woken.timers.size, 0)
const updating = fixture({ paused: true })
updating.watch.track("one")
updating.watch.track("two")
assert.equal(updating.reloads, 0)
@@ -19,11 +19,11 @@ function fixture() {
return { feed, requests, updates, timers, revoked, tick, reply, get unauthorized() { return unauthorized } }
}
assert.deepEqual(PREVIEW_SCENES.map(({ id }) => id), ["engaged", "aol", "long_only", "experimental", "braking", "stopped",
assert.deepEqual(PREVIEW_SCENES.map(({ id }) => id), ["general", "engaged", "aol", "long_only", "experimental", "braking", "stopped",
"cem_stop_light", "cem_lead", "cem_curve", "slc_pending", "slc_max", "slc_equal",
"slc_pedal", "slc_retained", "slc_disengaged", "slc_unavailable", "slc_display"])
assert.equal(PREVIEW_SCENES.find(({ id }) => id === "experimental").label, "Manual experimental")
for (const scene of ["cem_stop_light", "cem_lead", "cem_curve", "slc_pending", "slc_max", "slc_equal",
for (const scene of ["general", "cem_stop_light", "cem_lead", "cem_curve", "slc_pending", "slc_max", "slc_equal",
"slc_pedal", "slc_retained", "slc_disengaged", "slc_unavailable", "slc_display"]) {
const cem = fixture()
cem.feed.start(); cem.feed.update(document, "compact", scene); cem.tick()
@@ -99,6 +99,22 @@ assert.equal(timeout.requests[0].options.signal.aborted, true)
timeout.requests[0].reject(new Error("aborted")); await flush()
assert.equal(timeout.updates.at(-1).status, "unavailable")
// The backdrop asks for the real screen without widgets, and only its key decides when to refetch.
const backdrop = fixture()
backdrop.feed.backdrop = true
backdrop.feed.start()
backdrop.feed.update(document, "large", "aol", false, { palette: document.palette })
backdrop.tick()
assert.equal(JSON.parse(backdrop.requests[0].options.body).art, "backdrop")
assert.deepEqual(JSON.parse(backdrop.requests[0].options.body).document, document)
await backdrop.reply(0)
const moved = { ...document, layouts: { large: { current_speed: { x: 9, y: 9, enabled: true } }, compact: {} } }
backdrop.feed.update(moved, "large", "aol", false, { palette: document.palette })
assert.equal(backdrop.timers.size, 0)
backdrop.feed.update(moved, "large", "braking", false, { palette: document.palette })
assert.equal(backdrop.timers.size, 1)
backdrop.feed.stop()
const late = fixture()
late.feed.start(); late.feed.update(document, "large", "engaged"); late.tick(); late.feed.stop(); await late.reply(0)
assert.equal(late.updates.some((update) => update.url?.startsWith("blob:")), false)
@@ -94,6 +94,7 @@ class TestLayoutPreviewHttp(unittest.TestCase):
self.assertEqual(self.request('/api/ui/layout/preview', self.payload, cookie, origin='http://evil.example')[0], 403)
self.assertEqual(self.request('/api/ui/layout/preview', {**self.payload, 'extra': 1}, cookie)[0], 400)
self.assertEqual(self.request('/api/ui/layout/preview', {**self.payload, 'scene': 'unknown'}, cookie)[0], 400)
self.assertEqual(self.request('/api/ui/layout/preview', {**self.payload, 'art': 'widgets'}, cookie)[0], 400)
self.assertEqual(self.request('/api/ui/layout/preview', {**self.payload, 'document': {'version': 99}}, cookie)[0], 400)
self.assertEqual(self.request('/api/ui/layout/preview', cookie=cookie,
raw='{"scene":"aol","scene":"engaged"}')[0], 400)
@@ -5,7 +5,7 @@ const originalStorage = globalThis.localStorage
const saved = new Map()
const editor = projection => {
const vm = { ...OnroadLayoutPage.setup({ projection, mode: "local", unauthorized() {} }), projection }
for (const name of ["previewHidden", "togglePreviewVisibility"])
for (const name of ["sceneHidden", "previewHidden", "togglePreviewVisibility"])
vm[name] = OnroadLayoutPage.methods[name].bind(vm)
return vm
}
@@ -13,20 +13,33 @@ try {
globalThis.localStorage = { getItem: key => saved.get(key) ?? null, setItem: (key, value) => saved.set(key, value) }
for (const projection of [false, true]) {
const vm = editor(projection)
assert.equal(vm.previewHidden("stopped_timer"), true)
assert.equal(vm.state.scene, "general")
assert.equal(vm.previewHidden("stopped_timer"), false)
assert.equal(vm.previewHidden("current_speed"), false, "targets have separate preferences")
vm.togglePreviewVisibility("stopped_timer")
vm.togglePreviewVisibility("current_speed")
vm.state.profile = "compact"
vm.togglePreviewVisibility("driver_monitor")
const reopened = editor(projection)
assert.equal(reopened.previewHidden("stopped_timer"), false, "explicitly showing the timer survives reopening")
assert.equal(reopened.previewHidden("stopped_timer"), true, "General edit eye preferences survive reopening")
assert.equal(reopened.previewHidden("current_speed"), true)
assert.equal(reopened.previewHidden("driver_monitor"), false)
reopened.state.profile = "compact"
assert.equal(reopened.previewHidden("driver_monitor"), true)
assert.equal(reopened.previewHidden("current_speed"), false, "profiles have separate preferences")
assert.equal(reopened.state.draft, null, "visibility is independent of the saved driving layout")
reopened.state.scene = "stopped"
assert.equal(reopened.previewHidden("stopped_timer"), false, "General edit cannot hide the stopped scene's timer")
reopened.togglePreviewVisibility("stopped_timer")
assert.equal(reopened.previewHidden("stopped_timer"), true)
reopened.state.scene = "engaged"
assert.equal(reopened.previewHidden("stopped_timer"), true)
reopened.togglePreviewVisibility("stopped_timer")
assert.equal(reopened.previewHidden("stopped_timer"), true, "Driving scenes cannot show an impossible timer")
reopened.togglePreviewVisibility("current_speed")
reopened.state.scene = "experimental"
assert.equal(reopened.previewHidden("current_speed"), false, "Eye preferences do not leak between scenes")
assert.equal(reopened.previewHidden("stopped_timer"), true)
}
const aa = editor(true)
const before = [...saved]
@@ -34,11 +47,18 @@ try {
assert.equal(aa.previewHidden("car_exit"), false)
assert.deepEqual([...saved], before)
saved.delete('gx-layout-preview-visibility-v2:comma')
saved.set('gx-layout-preview-visibility-v1:comma', JSON.stringify({ large: { current_speed: true }, compact: { stopped_timer: true } }))
const migrated = editor(false)
assert.equal(migrated.previewHidden('current_speed'), true, 'Old preview preferences become General edit preferences')
migrated.state.profile = 'compact'
assert.equal(migrated.previewHidden('stopped_timer'), false, 'The old automatic timer default does not hide General edit samples')
for (const stored of ["broken JSON", "null", JSON.stringify({ large: [true], compact: "invalid" }),
JSON.stringify({ large: { stopped_timer: "false", current_speed: 1 } })]) {
globalThis.localStorage.getItem = () => stored
const vm = editor(false)
assert.equal(vm.previewHidden("stopped_timer"), true)
assert.equal(vm.previewHidden("stopped_timer"), false)
assert.equal(vm.previewHidden("current_speed"), false)
}
globalThis.localStorage = {
@@ -52,4 +72,4 @@ try {
if (originalStorage === undefined) delete globalThis.localStorage
else globalThis.localStorage = originalStorage
}
console.log("Preview visibility: persistence, target/profile isolation, timer default, required exit and unavailable storage passed")
console.log("Preview visibility: General edit persistence, scene/target/profile isolation, timer eligibility, required exit and unavailable storage passed")
@@ -0,0 +1,75 @@
import assert from "node:assert/strict"
import { SNAP_DISTANCE, snapTargets, snapMove, snapResize, snapThreshold } from "../web/js/layout-snap.js"
const copy = value => structuredClone(value)
// Every helper call also checks that its inputs are unchanged.
function pure(fn, ...args) {
const before = args.map(arg => typeof arg === "function" ? arg : copy(arg))
const result = fn(...args)
assert.deepEqual(args, before)
return result
}
const profile = { width: 1000, height: 600, bounds: { x: 30, y: 30, width: 900, height: 540 }, reservedZones: [], widgets: {
moving: { label: "Moving", kind: "clock", width: 100, height: 60 },
home: { label: "Home", kind: "favorite", width: 100, height: 60 },
} }
const layout = { moving: { x: 200, y: 200, enabled: true }, home: { x: 300, y: 350, enabled: true } }
const targets = pure(snapTargets, profile, layout, "moving", () => true)
const bounds = targets.filter(target => target.kind !== "widget")
const box = { x: 34, y: 113, width: 100, height: 60 }
let result = pure(snapMove, box, bounds, 6)
assert.equal(result.x, 30)
assert.equal(result.y, 113)
assert.deepEqual(result.guides, [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }])
assert.deepEqual(pure(snapMove, { ...box, x: 37 }, bounds, 6), { x: 37, y: 113, guides: [] })
result = pure(snapMove, { ...box, x: 433 }, bounds, 6)
assert.equal(result.x, 430)
assert.equal(result.guides[0].kind, "center")
result = pure(snapMove, { ...box, x: 404 }, targets, 6)
assert.equal(result.x, 400) // Start to another widget's end.
assert.equal(result.guides[0].label, "Home")
assert.deepEqual(pure(snapMove, { ...box, x: 253 }, targets, 6), { x: 253, y: 113, guides: [] })
assert.deepEqual(pure(snapMove, box, bounds, 0), { x: 34, y: 113, guides: [] })
const target = (value, line = "start", kind = "widget", axis = "x", label = "Home") => ({ axis, value, line, kind, label })
result = pure(snapMove, { ...box, x: 100 }, [target(104), target(146, "center", "center")], 6)
assert.equal(result.x, 96) // Equal distance prefers bounds center.
assert.equal(result.guides[0].kind, "center")
for (const kinds of [["zone", "widget"], ["widget", "bounds"], ["bounds", "center"]]) {
const first = target(104, "start", kinds[0]), second = target(96, "start", kinds[1])
assert.equal(pure(snapMove, { ...box, x: 100 }, [first, second], 6).x, 96)
}
assert.equal(pure(snapMove, { ...box, x: 100 }, [target(104), target(96)], 6).x, 104)
result = pure(snapMove, { ...box, x: 104 }, [target(100), target(200, "end"), target(100, "end", "bounds")], 6)
assert.deepEqual(result.guides, [
{ axis: "x", value: 100, kind: "bounds", label: "Home" },
{ axis: "x", value: 200, kind: "widget", label: "Home" },
])
const exclusions = copy(profile), placements = copy(layout)
for (const [id, kind, enabled] of [["disabled", "clock", false], ["hidden", "clock", true], ["torque", "torque_bar", true],
["pip", "pip_camera", true], ["invalid", "clock", true]]) {
exclusions.widgets[id] = { label: id, kind, layer: "underlay", width: 60, height: 60 }
placements[id] = { x: id === "invalid" ? Infinity : 100, y: 100, enabled }
}
exclusions.widgets.missing = { label: "missing", kind: "clock", width: 60, height: 60 }
const filtered = pure(snapTargets, exclusions, placements, "moving", id => id !== "hidden")
assert.deepEqual([...new Set(filtered.filter(item => item.kind === "widget").map(item => item.label))], ["Home", "pip"])
assert.deepEqual(pure(snapTargets, profile, layout, "absent", () => true), [])
const sidebar = copy(profile)
sidebar.widgets.moving.bounds = { x: 940, y: 0, width: 60, height: 600 }
assert.deepEqual(pure(snapTargets, sidebar, layout, "moving", () => false).filter(item => item.axis === "x").map(item => item.value), [940, 1000, 970])
const zones = copy(profile)
zones.reservedZones.push({ x: 150, y: 160, width: 100, height: 80 })
assert.deepEqual(pure(snapTargets, zones, layout, "moving", () => false).filter(item => item.kind === "zone").map(item => item.value), [150, 250, 160, 240])
const resizeBox = { x: 100, y: 150, width: 97, height: 146 }
result = pure(snapResize, resizeBox, [target(200), target(300, "end", "widget", "y")], 6)
assert.deepEqual(result, { width: 100, height: 150, guides: [
{ axis: "x", value: 200, kind: "widget", label: "Home" },
{ axis: "y", value: 300, kind: "widget", label: "Home" },
] })
assert.deepEqual(pure(snapResize, resizeBox, [target(200, "center")], 6), { width: 97, height: 146, guides: [] })
assert.deepEqual(SNAP_DISTANCE, { touch: 10, fine: 6 })
for (const [pointer, expected] of [["touch", 60], ["mouse", 36], ["pen", 36]])
assert.equal(pure(snapThreshold, { width: 2160 }, { width: 360 }, pointer), expected)
assert.equal(pure(snapThreshold, profile, { width: 0 }, "touch"), 0)
assert.equal(pure(snapThreshold, profile, { width: -1 }, "touch"), 0)
console.log("Layout snapping: targets, matching, priority, guides, resize, scaling and purity passed")
@@ -51,7 +51,7 @@ export function snapshot() {
metadata.roadColorFields = [{ id: 'path', label: 'Path', default: '#30FF9CFF' },
{ id: 'pathEdge', label: 'Path edges', default: '#00FF40FF' }, { id: 'laneLines', label: 'Lane lines', default: '#FFFFFFFF' }]
metadata.profiles.compact.widgets.speed_limit_actions.visualInsetTop = 32
const document = { version: 6, roadColors: { large: {}, compact: {} }, widgetColors: { large: {}, compact: {} }, palette: Object.fromEntries(metadata.paletteFields.map((field) => [field.id, field.default])),
const document = { version: 7, roadColors: { large: {}, compact: {} }, widgetColors: { large: {}, compact: {} }, palette: Object.fromEntries(metadata.paletteFields.map((field) => [field.id, field.default])),
layouts: Object.fromEntries(Object.entries(metadata.profiles).map(([id, profile]) => [id,
Object.fromEntries(Object.entries(profile.widgets).map(([key, definition]) => [key,
{ ...definition.default, ...(definition.resizable ? { size: definition.resizable.default } : {}) }]))])) }
@@ -125,7 +125,7 @@ assert.equal(clampPlacement(data.metadata.profiles.large, "current_speed", NaN,
assert.deepEqual(previewPoint({ clientX: 316, clientY: 200 }, { left: 100, top: 92, width: 432, height: 216 }, data.metadata.profiles.large), { x: 1080, y: 540 })
function editor() {
const emitted = [], calls = [], previewCalls = [], captures = new Set()
const emitted = [], calls = [], backdropCalls = [], captures = new Set()
const target = { setPointerCapture(id) { captures.add(id) }, hasPointerCapture(id) { return captures.has(id) }, releasePointerCapture(id) { captures.delete(id) } }
const vm = { ...OnroadLayoutPage.setup({ mode: "local", unauthorized() {} }), $emit: (...args) => emitted.push(args),
$refs: { preview: { ...target, getBoundingClientRect: () => ({ left: 100, top: 100, width: 1080, height: 540 }) } } }
@@ -135,16 +135,199 @@ function editor() {
vm.artFeed.start = () => { vm.artFeed.active = true }
vm.artFeed.update = () => {}
vm.artFeed.stop = () => { vm.artFeed.active = false }
vm.artFeed.pause = () => { vm.artFeed.active = false }
vm.backdropFeed.start = () => { vm.backdropFeed.active = true; backdropCalls.push("start") }
vm.backdropFeed.update = (...args) => backdropCalls.push(["update", ...args])
vm.backdropFeed.stop = () => { vm.backdropFeed.active = false; backdropCalls.push("stop") }
vm.state.data = snapshot(); vm.state.data.supportedProfiles = ['large', 'compact']; vm.state.draft = copy(vm.state.data.document); vm.state.status = "ready"; vm.state.selected = "current_speed"
const publish = vm.feed.publish
vm.feed = { load: () => calls.push("load"), save: (document) => calls.push(copy(document)) }
vm.previewFeed.start = () => { vm.previewFeed.active = true; previewCalls.push("start") }
vm.previewFeed.update = (...args) => previewCalls.push(["update", ...args])
vm.previewFeed.stop = () => { vm.previewFeed.active = false; previewCalls.push("stop") }
const event = (x, y, pointerId = 1) => ({ clientX: x, clientY: y, pointerId, button: 0, currentTarget: { setPointerCapture() { throw new Error("Capture must belong to the stable preview") } }, preventDefault() {} })
return { vm, event, emitted, calls, previewCalls, captures, publish }
return { vm, event, emitted, calls, backdropCalls, captures, publish }
}
// Snapping changes pointer placement only; a drag remains one history entry.
const snappedDrag = editor(), snapVm = snappedDrag.vm
const largePoint = (x, y, options = {}) => ({ ...snappedDrag.event(100 + x / 2, 100 + y / 2), pointerType: "mouse", ...options })
snapVm.startDrag("current_speed", largePoint(640, 30))
snapVm.moveDrag(largePoint(34, 430))
assert.equal(snapVm.layout.current_speed.x, 30)
assert.ok(snapVm.state.snapGuides.some(guide => guide.axis === "x" && guide.kind === "bounds"))
assert.equal(snapVm.snapSummary, "At the edge")
const cachedTargets = snapVm.state.drag.targets
snapVm.moveDrag(largePoint(35, 430))
assert.equal(snapVm.state.drag.targets, cachedTargets)
assert.equal(snapVm.snapInBar, false, "a mouse drag keeps the alignment chip on the preview")
snapVm.endDrag(largePoint(34, 430))
snapVm.startDrag("current_speed", largePoint(34, 430, { pointerType: "touch" }))
snapVm.moveDrag(largePoint(34, 300, { pointerType: "touch" })) // past the finger slop
snapVm.moveDrag(largePoint(35, 430, { pointerType: "touch" }))
assert.equal(snapVm.snapInBar, true, "a finger drag shows the alignment in the save bar instead")
snapVm.endDrag(largePoint(35, 430, { pointerType: "touch" }))
assert.equal(snapVm.snapInBar, false)
assert.deepEqual(snapVm.state.snapGuides, [])
assert.equal(snapVm.state.history.undo.length, 1)
snapVm.undo()
assert.deepEqual(snapVm.layout.current_speed, snapshot().document.layouts.large.current_speed)
for (const bypass of ["alt", "disabled"]) {
const free = editor().vm
if (bypass === "disabled") free.state.snapEnabled = false
free.startDrag("current_speed", largePoint(640, 30))
free.endDrag(largePoint(34, 430, { altKey: bypass === "alt" }))
assert.equal(free.layout.current_speed.x, 34)
assert.deepEqual(free.state.snapGuides, [])
}
snapVm.startDrag("current_speed", largePoint(640, 30))
snapVm.moveDrag(largePoint(34, 430))
assert.ok(snapVm.state.snapGuides.length)
snapVm.cancelDrag()
assert.deepEqual(snapVm.layout.current_speed, snapshot().document.layouts.large.current_speed)
assert.deepEqual(snapVm.state.snapGuides, [])
snapVm.changePosition("current_speed", 34, 430)
snapVm.onKey("current_speed", { key: "ArrowLeft", preventDefault() {} })
assert.equal(snapVm.layout.current_speed.x, 33)
snapVm.onKey("current_speed", { key: "ArrowRight", shiftKey: true, preventDefault() {} })
assert.equal(snapVm.layout.current_speed.x, 43)
assert.deepEqual(snapVm.state.snapGuides, [])
for (const pointerType of ["mouse", "touch"]) {
const phone = editor().vm
phone.$refs.preview.getBoundingClientRect = () => ({ left: 0, top: 0, width: 360, height: 180 })
for (const id of Object.keys(phone.layout)) if (id !== "current_speed") phone.state.hiddenPreviewWidgets.large[id] = true
const phonePoint = (x, y) => ({ ...snappedDrag.event(x / 6, y / 6), pointerType })
phone.startDrag("current_speed", phonePoint(640, 30))
phone.endDrag(phonePoint(75, 430))
assert.equal(phone.layout.current_speed.x, pointerType === "touch" ? 30 : 75)
}
const targetVisibility = editor().vm
targetVisibility.profile.widgets.pip = { ...widget("Side camera", "pip_camera", 60, 60, 400, 300), layer: "underlay" }
targetVisibility.layout.pip = { x: 400, y: 300, enabled: true }
targetVisibility.startDrag("current_speed", largePoint(640, 30))
targetVisibility.moveDrag(largePoint(464, 430))
assert.equal(targetVisibility.layout.current_speed.x, 460)
assert.ok(targetVisibility.state.snapGuides.some(guide => guide.label === "Side camera"))
targetVisibility.cancelDrag()
targetVisibility.togglePreviewVisibility("pip")
targetVisibility.startDrag("current_speed", largePoint(640, 30))
targetVisibility.moveDrag(largePoint(464, 430))
assert.equal(targetVisibility.layout.current_speed.x, 464)
targetVisibility.cancelDrag()
targetVisibility.startDrag("current_speed", largePoint(640, 30))
targetVisibility.moveDrag(largePoint(443, 430))
assert.equal(targetVisibility.layout.current_speed.x, 443, "torque arc footprint must not attract other widgets")
targetVisibility.cancelDrag()
const invalidSnap = editor().vm
invalidSnap.selectProfile("compact")
invalidSnap.$refs.preview.getBoundingClientRect = () => ({ left: 0, top: 0, width: 536, height: 240 })
invalidSnap.state.hiddenPreviewWidgets.compact.speed_limit_actions = true
const smallPoint = (x, y) => ({ ...snappedDrag.event(x, y), pointerType: "mouse" })
invalidSnap.startDrag("max_speed", smallPoint(0, 0))
invalidSnap.moveDrag(smallPoint(100, 18)) // Speed limit's top attracts to y=20, overlapping protected actions.
assert.equal(clampPlacement(invalidSnap.profile, "max_speed", 100, 20, invalidSnap.layout), null)
assert.deepEqual(invalidSnap.layout.max_speed, { x: 100, y: 18, enabled: true })
assert.deepEqual(invalidSnap.state.snapGuides, [])
assert.equal(invalidSnap.state.placementError, "")
invalidSnap.moveDrag(smallPoint(100, 100))
assert.deepEqual(invalidSnap.layout.max_speed, { x: 100, y: 18, enabled: true })
assert.ok(invalidSnap.state.placementError)
invalidSnap.cancelDrag()
const traySnap = editor().vm
traySnap.layout.current_speed.enabled = false
traySnap.startDrag("current_speed", largePoint(2400, 0), true)
traySnap.moveDrag(largePoint(324, 550)) // Widget center follows the tray pointer.
assert.equal(traySnap.layout.current_speed.x, 30)
assert.equal(traySnap.layout.current_speed.enabled, true)
assert.ok(traySnap.state.snapGuides.length)
traySnap.moveDrag(largePoint(2400, 550))
assert.equal(traySnap.layout.current_speed.enabled, false)
assert.deepEqual(traySnap.state.snapGuides, [])
traySnap.cancelDrag()
assert.equal(traySnap.layout.current_speed.enabled, false)
const clampedSnap = editor().vm
clampedSnap.profile.widgets.driver_monitor.bounds = { x: 30, y: 30, width: 300, height: 1020 }
clampedSnap.layout.current_speed.x = 184
clampedSnap.startDrag("driver_monitor", largePoint(88, 808))
clampedSnap.moveDrag(largePoint(187, 400)) // Current speed at 184 is beyond the mover's own bounds.
assert.equal(clampedSnap.layout.driver_monitor.x, 138)
assert.deepEqual(clampedSnap.state.snapGuides.filter(guide => guide.axis === "x"),
[{ axis: "x", value: 330, kind: "bounds", label: "the edge" }], "a clamped widget reports the edge it rests on, not the target it missed")
clampedSnap.moveDrag(largePoint(400, 400)) // Pushed far past the edge, it still reports resting on it.
assert.equal(clampedSnap.layout.driver_monitor.x, 138)
assert.ok(clampedSnap.state.snapGuides.some(guide => guide.axis === "x" && guide.value === 330 && guide.kind === "bounds"))
clampedSnap.cancelDrag()
const resizeSnap = editor().vm
resizeSnap.profile.widgets.map = { ...widget("Map", "nav_map", 200, 200, 300, 300),
box: { minWidth: 100, maxWidth: 1000, minHeight: 100, maxHeight: 1000 } }
resizeSnap.layout.map = { x: 300, y: 300, width: 200, height: 200, enabled: true }
const resizePoint = (x, y, options = {}) => ({ ...largePoint(x, y, options), stopPropagation() {} })
resizeSnap.startResize("map", resizePoint(500, 500))
resizeSnap.moveDrag(resizePoint(564, 671))
assert.deepEqual([resizeSnap.layout.map.width, resizeSnap.layout.map.height], [268, 375])
assert.deepEqual([resizeSnap.layout.map.x, resizeSnap.layout.map.y], [300, 300])
assert.equal(resizeSnap.state.snapGuides.length, 2)
resizeSnap.endDrag(resizePoint(564, 671))
assert.deepEqual(resizeSnap.state.snapGuides, [])
assert.equal(resizeSnap.state.history.undo.length, 1)
resizeSnap.undo()
resizeSnap.startResize("map", resizePoint(500, 500))
resizeSnap.moveDrag(resizePoint(564, 671, { altKey: true }))
assert.deepEqual([resizeSnap.layout.map.width, resizeSnap.layout.map.height], [264, 371])
assert.deepEqual(resizeSnap.state.snapGuides, [])
resizeSnap.cancelDrag()
resizeSnap.profile.widgets.map.box.maxWidth = 250
resizeSnap.startResize("map", resizePoint(500, 500))
resizeSnap.moveDrag(resizePoint(564, 671))
assert.equal(resizeSnap.layout.map.width, 250)
assert.deepEqual(resizeSnap.state.snapGuides, [])
resizeSnap.cancelDrag()
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"), preferences = new Map()
try {
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: key => preferences.get(key) ?? null, setItem: (key, value) => preferences.set(key, value)
} })
const preference = editor().vm
assert.equal(preference.state.snapEnabled, true)
preference.toggleSnap()
assert.equal(preferences.get("gx-layout-snap-v1"), "0")
assert.equal(editor().vm.state.snapEnabled, false)
assert.equal(OnroadLayoutPage.setup({ projection: true }).state.snapEnabled, false)
preference.toggleSnap()
assert.equal(preferences.get("gx-layout-snap-v1"), "1")
assert.equal(editor().vm.state.snapEnabled, true)
Object.defineProperty(globalThis, "localStorage", { configurable: true, get() { throw new Error("Storage blocked") } })
const blocked = editor().vm
assert.equal(blocked.state.snapEnabled, true)
blocked.toggleSnap()
assert.equal(blocked.state.snapEnabled, false)
} finally {
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage)
else delete globalThis.localStorage
}
for (const [guides, expected] of [
[[{ axis: "x", kind: "center" }, { axis: "y", kind: "center" }], "Centered on the screen"],
[[{ axis: "x", kind: "center" }], "Centered across"],
[[{ axis: "y", kind: "center" }], "Centered top to bottom"],
[[{ label: "Home" }, { label: "Home" }, { label: "Work" }], "Lined up with Home and Work"],
[[{ label: "the edge" }, { label: "Home" }], "At the edge, lined up with Home"],
[[{ axis: "x", kind: "center", label: "the center" }, { axis: "y", kind: "bounds", label: "the edge" }], "Centered across, at the edge"],
[[{ axis: "y", kind: "center", label: "the center" }, { axis: "x", kind: "widget", label: "Home" }, { axis: "y", kind: "widget", label: "Work" }],
"Centered top to bottom, lined up with Home"],
]) {
snapVm.state.snapGuides = guides
assert.equal(snapVm.snapSummary, expected)
}
for (const clear of [() => snapVm.selectProfile("compact")]) {
snapVm.state.snapGuides = [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }]
clear()
assert.deepEqual(snapVm.state.snapGuides, [])
}
console.log("Layout snapping integration: drag, touch, targets, fallback, resize, history, preference and keyboard passed")
// Layer visibility is local to the arrangement preview, including required widgets.
for (const projection of [false, true]) {
const visibility = editor().vm
@@ -231,8 +414,10 @@ torqueEditor.remove("torque_bar")
assert.equal(torqueEditor.layout.torque_bar.enabled, false)
torqueEditor.add("torque_bar")
assert.equal(torqueEditor.layout.torque_bar.enabled, true)
const { vm, event, emitted, calls, previewCalls, captures } = editor()
assert.equal(vm.state.devicePreviewOpen, false)
const { vm, event, emitted, calls, backdropCalls, captures } = editor()
// The comma's real screen loads behind the arrangement with the editor.
assert.deepEqual(backdropCalls.map((call) => Array.isArray(call) ? call[0] : call).slice(-2), ["start", "update"])
assert.deepEqual(backdropCalls.at(-1).slice(2, 5), ["large", "general", false])
assert.equal(vm.dirty, false)
vm.changePosition("current_speed", 700, 200)
vm.selectProfile("compact")
@@ -296,41 +481,80 @@ assert.equal(vm.selectedColors.text, "#FFFFFFFF")
vm.colorRgb("text", { target: { value: "#abcdef" } })
vm.colorAlpha("text", { target: { value: "128" } })
assert.equal(vm.selectedColors.text, "#ABCDEF80")
assert.deepEqual(previewCalls, []) // Editing does not select or request Device preview.
const draftBeforePreview = copy(vm.state.draft)
vm.mode = "local"
vm.showDevicePreview()
assert.equal(vm.state.devicePreviewOpen, true)
assert.equal(vm.state.previewMode, "device")
assert.deepEqual(previewCalls.map((call) => Array.isArray(call) ? call[0] : call), ["start", "update"])
assert.deepEqual(previewCalls[1].slice(1), [draftBeforePreview, "large", "engaged", false])
// Driving scenes do not leave a CEM editing placeholder behind.
assert.doesNotMatch(OnroadLayoutPage.template, /data-preview-source="cem-hint"/)
// The Small screen shows the tap-areas chip under the layout instead of over it.
assert.match(OnroadLayoutPage.template, /state\.profile !== 'compact'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"><\/i>Tap areas/)
assert.match(OnroadLayoutPage.template, /state\.profile === 'compact'" class="gx-layout__stage-chip gx-layout__stage-chip--below"/)
// One scene dropdown, grouped, with every scene exactly once.
assert.deepEqual(vm.sceneGroups.map(group => group.label), ["Editing", "Driving", "Conditional experimental", "Speed limit"])
assert.deepEqual(vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id)).sort(), vm.scenes.map(scene => scene.id).sort())
// The Small screen only offers scenes that look different there; a hidden pick falls back to General edit.
vm.state.scene = "slc_display"
vm.selectProfile("compact")
assert.equal(previewCalls.at(-1)[2], "compact")
assert.equal(vm.state.scene, "general")
const smallScenes = vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id))
for (const id of ["slc_pedal", "slc_retained", "slc_display"]) assert.ok(!smallScenes.includes(id), id)
assert.ok(smallScenes.includes("slc_unavailable") && smallScenes.includes("slc_disengaged"))
vm.selectProfile("large")
assert.equal(vm.sceneGroups.flatMap(group => group.scenes).length, vm.scenes.length)
assert.match(OnroadLayoutPage.template, /<GalaxySelect class="gx-field gx-field--full" aria-label="Preview scene"/)
// The widget open in Edit glows; the sources panel is a layout setting, not an on-screen toggle.
assert.match(OnroadLayoutPage.template, /'is-editing': state\.showEditingAids && state\.inspectorPanel === 'edit' && state\.selected === widget\.id/)
// Sources sit inside the cruise card (or a card right under it), so its box grows and actions right below follow.
vm.state.data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent = { inline: 76, card: 84 }
vm.state.selected = "cruise_limits"
const sourcesCard = vm.layout.cruise_limits, sourcesActions = vm.layout.speed_limit_actions
const actionsBefore = { ...sourcesActions }
Object.assign(sourcesActions, { x: sourcesCard.x, y: sourcesCard.y + 522 + 22 })
vm.setSpeedSources("speedSources", true)
assert.equal(vm.state.draft.speedSources, true)
assert.equal(vm.profile.widgets.cruise_limits.height, 522 + 76)
assert.equal(sourcesActions.y, sourcesCard.y + 522 + 76 + 22)
vm.setSpeedSources("speedSourcesCard", true)
assert.equal(vm.profile.widgets.cruise_limits.height, 522 + 84)
assert.equal(sourcesActions.y, sourcesCard.y + 522 + 84 + 22)
vm.undo(); vm.undo()
assert.notEqual(vm.state.draft.speedSources, true)
assert.equal(vm.profile.widgets.cruise_limits.height, 522)
assert.equal(vm.layout.speed_limit_actions.y, vm.layout.cruise_limits.y + 522 + 22)
Object.assign(vm.layout.speed_limit_actions, actionsBefore)
vm.state.selected = "current_speed"
vm.setSpeedSources("speedSources", true)
assert.notEqual(vm.state.draft.speedSources, true) // Only from the cruise widget's own panel.
vm.state.selected = "cruise_limits"
vm.setSpeedAlign("center")
assert.equal(vm.state.draft.speedAlign, "center")
vm.setSpeedAlign("justify")
assert.equal(vm.state.draft.speedAlign, "center") // Only left, center or right.
vm.undo()
assert.equal(vm.state.draft.speedAlign ?? "left", "left")
vm.setSpeedSources("palette", true)
assert.equal(vm.state.draft.palette === true, false) // Only the two sources settings.
// Moving or removing widgets keeps the same backdrop; scene and size changes replace it.
const backdropCount = backdropCalls.length
vm.changePosition("current_speed", 720, 220)
vm.changePosition("current_speed", 510, 250)
vm.remove("current_speed")
vm.add("current_speed")
assert.equal(backdropCalls.length, backdropCount)
vm.selectProfile("compact")
assert.equal(backdropCalls.at(-1)[2], "compact")
vm.state.scene = "braking"
assert.equal(previewCalls.at(-1)[3], "braking")
assert.equal(backdropCalls.at(-1)[3], "braking")
vm.selectProfile("large")
vm.hideDevicePreview()
assert.equal(vm.state.devicePreviewOpen, false)
assert.equal(vm.previewFeed.active, false)
assert.equal(previewCalls.at(-1), "stop")
assert.deepEqual(copy(vm.state.draft), draftBeforePreview)
const previewCount = previewCalls.length
vm.state.scene = "engaged"
vm.color("text", "#ABCDEF81")
assert.equal(previewCalls.length, previewCount)
assert.equal(backdropCalls.length, backdropCount + 5) // Color edits change the real screen too.
vm.color("text", "#ABCDEF80")
const deviceEditor = editor()
deviceEditor.vm.mode = "local"
deviceEditor.vm.showDevicePreview()
const beforeDeviceDrag = copy(deviceEditor.vm.layout.current_speed)
deviceEditor.vm.startDrag("current_speed", deviceEditor.event(500, 200))
assert.equal(deviceEditor.vm.state.devicePreviewOpen, true)
assert.equal(deviceEditor.previewCalls.at(-1).at(-1), true)
deviceEditor.vm.moveDrag(deviceEditor.event(700, 250))
deviceEditor.vm.cancelDrag()
assert.deepEqual(copy(deviceEditor.vm.layout.current_speed), beforeDeviceDrag)
assert.equal(deviceEditor.vm.state.devicePreviewOpen, true)
deviceEditor.vm.hideDevicePreview()
const draggingCount = backdropCalls.length
vm.startDrag("current_speed", event(500, 200))
vm.moveDrag(event(700, 250))
vm.cancelDrag()
assert.equal(backdropCalls.length, draggingCount)
vm.state.data.editable = false
assert.equal(backdropCalls.at(-1), "stop")
vm.state.data.editable = true
vm.requestLeave("reload")
assert.equal(vm.state.discard, "reload")
assert.equal(calls.length, 0)
@@ -579,14 +803,14 @@ 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, /<section v-if="state\.devicePreviewOpen" class="gx-layout__device"/)
assert.match(editorSource, /<img v-if="state\.preview\.url"/)
assert.match(editorSource, /<image v-if="nativeBackdropReady" :href="state\.backdrop\.url"/)
assert.doesNotMatch(editorSource, /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" },
}) })
assert.ok(loadedCatalog.tools.some(tool => tool.path === "/theme_maker"))
assert.equal(catalog.tools.find((tool) => tool.path === "/theme_maker")?.availability, "local-only")
console.log("Colors and layout: stable editor, explicit Device preview, placement, drag/cancel, resets, save races, errors, auth and Visual navigation passed")
console.log("Colors and layout: stable editor, device backdrop, placement, drag/cancel, resets, save races, errors, auth and Visual navigation passed")
const railEditor = editor().vm
railEditor.selectProfile("compact")
@@ -46,6 +46,7 @@ class TestOnroadLayoutOwner(unittest.TestCase):
def test_v1_read_is_non_mutating_and_widget_colors_save_with_source_revision(self):
old = default_document()
old['version'] = 1
del old['layouts']['large']['cem_gauge'] # Version 1 predates the CEM gauge.
del old['widgetColors']
del old['roadColors']
for profile in old['layouts'].values():
@@ -59,6 +60,7 @@ class TestOnroadLayoutOwner(unittest.TestCase):
self.assertTrue(snapshot['valid'])
self.assertEqual(path.read_bytes(), raw)
expected_layouts = copy.deepcopy(old['layouts'])
expected_layouts['large']['cem_gauge'] = default_document()['layouts']['large']['cem_gauge']
for profile, placements in expected_layouts.items():
placements['steering_wheel']['size'] = default_document()['layouts'][profile]['steering_wheel']['size']
self.assertEqual(snapshot['document']['layouts'], expected_layouts)
@@ -11,14 +11,17 @@ metadata.width = 2880
metadata.bounds.width = 2820
metadata.widgets.current_speed.default.x += 510
metadata.widgets.steering_wheel.default.x += 1020
metadata.widgets.driver_monitor.width = metadata.widgets.driver_monitor.height = 128
metadata.widgets.driver_monitor.default = {x: 120, y: 840, enabled: true, size: 128}
metadata.widgets.driver_monitor.resizable = {min: 64, default: 128, max: 192}
metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96, height: 96, colors: {}, required: true, frontmost: true,
opacity: { min: 25, max: 100, default: 100 }, backgroundToggle: true,
opacity: { min: 25, max: 100, default: 75 }, backgroundToggle: true,
note: "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.",
default: { x: 30, y: 954, enabled: true, opacity: 100, background: true } }
default: { x: 30, y: 954, enabled: true, opacity: 75, background: false } }
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"]
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
const doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, canvas: { width: 2880, height: 1080 }, widgets }
const doc = { version: 3, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "left", canvas: { width: 2880, height: 1080 }, widgets }
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
revision: "aa-screen-and-layout", editable: true, valid: true,
@@ -26,6 +29,16 @@ const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
const data = editorSnapshot(raw)
assert.equal(validSnapshot(data), true)
assert.equal(validDocument(data.document, data.metadata), true)
assert.equal(data.document.layouts.large.car_exit.opacity, 75)
assert.equal(data.document.layouts.large.car_exit.background, false)
const resizedMonitor = copy(data.document)
resizedMonitor.layouts.large.driver_monitor.size = 192
assert.equal(validDocument(resizedMonitor, data.metadata), true)
assert.equal(projectionPayload({document: resizedMonitor}, data.metadata).document.widgets.driver_monitor.size, 192)
resizedMonitor.layouts.large.driver_monitor.size = 193
assert.equal(validDocument(resizedMonitor, data.metadata), false)
assert.match(OnroadLayoutPage.template, /switchRow\('Show background', selectedPosition.background === true\)/)
assert.doesNotMatch(OnroadLayoutPage.template, /type="checkbox" :checked="selectedPosition.background"/)
const exitOpacity = copy(data.document)
exitOpacity.layouts.large.car_exit.opacity = 24
assert.equal(validDocument(exitOpacity, data.metadata), false)
@@ -46,6 +59,9 @@ assert.equal(validSnapshot({ ...data, metadata: { ...data.metadata, projection:
assert.equal(validDocument(data.document, native.metadata), false)
assert.deepEqual(projectionPayload({ revision: raw.revision, document: data.document }, data.metadata),
{ revision: raw.revision, document: raw.document })
data.document.speedSources = true // The sources panel is an Android Auto setting, saved with the layout.
assert.equal(projectionPayload({ revision: raw.revision, document: data.document }, data.metadata).document.speedSources, true)
data.document.speedSources = false
data.document.clock24Hour = true
assert.equal(projectionPayload({ revision: raw.revision, document: data.document }, data.metadata).document.clock24Hour, true)
data.document.clock24Hour = false
@@ -53,7 +69,9 @@ data.document.largeUiGammaTrial = true
assert.equal(projectionPayload({document: data.document}, data.metadata).document.largeUiGammaTrial, true)
assert.equal(validDocument({...data.document, largeUiGammaTrial: 1}, data.metadata), false)
data.document.largeUiGammaTrial = false
assert.match(OnroadLayoutPage.template, /Large UI Gamma Effect Trial/)
assert.match(OnroadLayoutPage.template, /'Large UI Gamma Effect'/)
assert.doesNotMatch(OnroadLayoutPage.template, /Effect Trial|type="checkbox" :checked="state\.draft\.wheelPedalFeedback/)
assert.match(OnroadLayoutPage.template, /switchRow\('Brake and Acceleration Colors', state\.draft\.wheelPedalFeedback === true\)/)
const gammaChanges = []
const gammaVm = { projection: true, editable: true,
state: { draft: { largeUiGammaTrial: false }, drag: null, layerDrag: null, notice: '' },
@@ -70,10 +88,33 @@ for (const blocked of [{ projection: false }, { editable: false }]) {
assert.equal(data.document.blindSpotBorder, false)
assert.equal(data.document.signalBorder, false)
assert.equal(editorSnapshot({ ...raw, document: { ...raw.document, blindSpotBorder: undefined, signalBorder: undefined } }).document.blindSpotBorder, false)
for (const key of ["blindSpotBorder", "signalBorder", "hidePersonalityAlerts", "wheelPedalFeedback"]) assert.equal(validDocument({ ...data.document, [key]: 1 }, data.metadata), false)
for (const key of ["blindSpotBorder", "signalBorder", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]) assert.equal(validDocument({ ...data.document, [key]: 1 }, data.metadata), false)
// Android Auto's lead marker and lead info start off and never follow the comma's choices.
assert.equal(data.document.leadIndicator, false)
assert.equal(data.document.leadInfo, "Off")
const legacyLead = editorSnapshot({ ...raw, document: { ...raw.document, leadIndicator: undefined, leadInfo: undefined } }).document
assert.deepEqual([legacyLead.leadIndicator, legacyLead.leadInfo], [false, "Off"])
assert.equal(validDocument({ ...data.document, leadInfo: "Speed" }, data.metadata), true)
assert.equal(validDocument({ ...data.document, leadInfo: "Both" }, data.metadata), false)
assert.equal(validDocument({ ...native.document, leadIndicator: true }, native.metadata), false, "the comma screen keeps its own lead settings")
assert.equal(projectionPayload({ document: { ...data.document, leadIndicator: true, leadInfo: "Distance" } }, data.metadata).document.leadInfo, "Distance")
assert.match(OnroadLayoutPage.template, /'Lead Indicator'/)
const leadChanges = []
const leadVm = { projection: true, editable: true, state: { draft: { leadIndicator: false, leadInfo: "Off" }, drag: null, layerDrag: null, notice: '' },
recordChange: before => leadChanges.push(before) }
OnroadLayoutPage.methods.setProjectionLeadInfo.call(leadVm, "Speed")
assert.equal(leadVm.state.draft.leadInfo, "Off", "lead info waits for the lead marker")
assert.equal(OnroadLayoutPage.methods.projectionLeadInfoRow.call(leadVm).available, false)
OnroadLayoutPage.methods.setProjectionDisplay.call(leadVm, "leadIndicator", "On")
OnroadLayoutPage.methods.setProjectionLeadInfo.call(leadVm, "Speed")
assert.deepEqual([leadVm.state.draft.leadIndicator, leadVm.state.draft.leadInfo], [true, "Speed"])
assert.deepEqual(OnroadLayoutPage.methods.projectionLeadInfoRow.call(leadVm).choices, ["Off", "Distance", "Speed"])
OnroadLayoutPage.methods.setProjectionLeadInfo.call(leadVm, "Both")
assert.equal(leadVm.state.draft.leadInfo, "Speed")
assert.equal(leadChanges.length, 2)
assert.equal(validDocument({ ...native.document, signalBorder: true }, native.metadata), false, "the comma screen keeps its C4 settings")
assert.match(OnroadLayoutPage.template, /Red blind-spot border/)
assert.match(OnroadLayoutPage.template, /Amber signal border/)
assert.match(OnroadLayoutPage.template, /Red Blind-Spot Border/)
assert.match(OnroadLayoutPage.template, /Amber Signal Border/)
const borderChanges = []
const borderVm = { projection: true, editable: true, state: { draft: { blindSpotBorder: false, signalBorder: false }, drag: null, layerDrag: null, notice: '' },
recordChange: before => borderChanges.push(before) }
@@ -95,7 +136,7 @@ assert.equal(editorSnapshot(olderCameraLayout).document.blindSpotCamera, false)
for (const value of [1, 0, "true", null, [], {}])
assert.equal(validDocument({ ...data.document, blindSpotCamera: value }, data.metadata), false)
assert.equal(validDocument({ ...native.document, blindSpotCamera: true }, native.metadata), false)
assert.match(OnroadLayoutPage.template, /Show camera on blind-spot warning/)
assert.match(OnroadLayoutPage.template, /Show Camera on Blind-Spot Warning/)
assert.match(OnroadLayoutPage.template, /setProjectionDisplay\('blindSpotCamera', value\)/)
const cameraChanges = []
const cameraVm = { projection: true, editable: true,
@@ -147,7 +188,7 @@ compile(OnroadLayoutPage.template, { decodeEntities: value => value.replaceAll("
const emissions = [], leaveVm = { projection: true, busy: false, dirty: true,
state: { status: "ready", data: { editable: true }, draft: {}, drag: null, layerDrag: null, discard: null, leavePresentation: null, removeConfirm: null, error: "", needsReload: false },
hideDevicePreview() {}, $emit: (...args) => emissions.push(args), feed: { load() {}, async save() { return true } } }
$emit: (...args) => emissions.push(args), feed: { load() {}, async save() { return true } } }
for (const name of ["pendingLeave", "inlineLeavePrompt", "modalLeavePrompt", "saveAvailable", "canSavePending"])
Object.defineProperty(leaveVm, name, { get: () => OnroadLayoutPage.computed[name].call(leaveVm) })
for (const name of ["requestLeave", "cancelLeave", "leave", "saveAndLeave"])
@@ -192,8 +233,8 @@ const draft = copy(feed.data.document)
draft.layouts.large.current_speed.x += 20
assert.equal(await feed.save(draft), true)
assert.equal(requests[0].url, "./api/android-auto/layout")
assert.equal(requests[1].body.document.version, 2)
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "signalBorder", "version", "wheelPedalFeedback", "widgets"])
assert.equal(requests[1].body.document.version, 3)
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedAlign", "speedSources", "speedSourcesCard", "version", "wheelPedalFeedback", "widgets"])
assert.equal(requests[1].body.document.widgets.current_speed.x, 1170)
assert.equal(updates.at(-1).notice, "Android Auto layout saved.")
feed.stop()
@@ -34,7 +34,7 @@ for (const key of ['nav_home', 'nav_work', 'bookmark', 'car_exit']) {
metadata.widgetOrder = ["nav_map", ...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "nav_card", "nav_home", "nav_work", "bookmark", "car_exit"]
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
const doc = { version: 2, clock24Hour: false, canvas: { width: 2880, height: 1080 }, widgets }
const doc = { version: 3, clock24Hour: false, canvas: { width: 2880, height: 1080 }, widgets }
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
revision: "aa-map", editable: true, valid: true,
@@ -95,6 +95,7 @@ const vm = {
editable: true, $refs: { preview: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 2880, height: 1080 }),
setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} } },
finishColorEdit() { this.state.history.group = null },
sceneHidden() { return false },
}
Object.defineProperties(vm, {
canUndo: { get: () => vm.state.history.undo.length > 0 },
@@ -182,7 +183,7 @@ for (const [index, key] of ['nav_home', 'nav_work', 'bookmark', 'car_exit'].entr
vm.finishColorEdit()
const before = copy(vm.state.draft)
vm.opacityInput({target: {value: String(25 + index * 10)}})
vm.setWidgetBackground({target: {checked: false}})
vm.setWidgetBackground('Off')
assert.equal(vm.layout[key].opacity, 25 + index * 10)
assert.equal(vm.layout[key].background, false)
vm.undo()
@@ -206,7 +207,7 @@ for (const [index, key] of ['nav_home', 'nav_work', 'bookmark', 'car_exit'].entr
}
for (const blocked of [{editable: false}, {state: {...vm.state, drag: {}}}, {state: {...vm.state, layerDrag: {}}}]) {
OnroadLayoutPage.methods.opacityInput.call({...vm, ...blocked}, {target: {value: '90'}})
OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, {target: {checked: true}})
OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, 'On')
assert.equal(vm.layout[key].opacity, 25 + index * 10)
assert.equal(vm.layout[key].background, false)
}
@@ -222,7 +222,7 @@ try {
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
const nextLoad = [...bounded.timers.values()].find(({ ms }) => ms !== 4000)
assert.equal(nextLoad.ms, 200, 'pending fragments use the paced retry rather than a 50ms poll')
assert.equal(nextLoad.ms, 50, 'pending fragments retry after 50ms during playback')
bounded.client.buffer.buffered = ranges(0, 1.4)
bounded.video.currentTime = .1
const beforeFull = bounded.requests.length
@@ -3,7 +3,7 @@ import { validDocument } from "../web/js/onroad-layout.js"
const metadata = { profiles: Object.fromEntries(["large", "compact"].map(name => [name, {
bounds: {x:0,y:0,width:100,height:100}, widgets:{}, reservedZones:[]
}])) }
const document = { version:6, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"},
const document = { version:7, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"},
layouts:{large:{},compact:{}},widgetColors:{large:{},compact:{}},roadColors:{large:{},compact:{}} }
assert.equal(validDocument(document, metadata), true)
for (const flag of [true, false]) assert.equal(validDocument({...document,speedSources:flag},metadata),true)
@@ -64,6 +64,23 @@ for (const request of [null, { state: 'failed' }, { state: 'complete', outcome:
assert.equal(recovery.reloads, 1)
}
const rebooted = fixture({ ...baseline, instance: 'one' })
rebooted.requests[0].resolve({ ...status(), instance: 'one' })
await flush()
assert.equal(rebooted.reloads, 0)
rebooted.tick()
rebooted.requests[1].resolve({ ...status(), instance: 'two' })
await flush()
assert.equal(rebooted.reloads, 1)
const busy = fixture({ ...baseline, instance: 'one' })
busy.requests[0].resolve({ ...status(baseline.commit, pending), instance: 'two' })
await flush()
assert.equal(busy.reloads, 0)
const kept = fixture()
kept.recovery.start({ ...baseline, instance: 'one' })
kept.recovery.start({ ...baseline, instance: 'two' })
assert.equal(JSON.parse(kept.values.get(UPDATE_RESTART_KEY)).instance, 'one')
// Only update-related restarts/disconnects trigger the full-screen handoff.
const restarts = []
const feed = new SoftwareStatusFeed({ publish() {}, restarting: value => restarts.push(value) })
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import {deflateSync} from 'node:zlib'
import {LayoutPreviewFeed} from '../web/js/layout-preview.js'
import {LayoutPreviewFeed,PreviewQueue} from '../web/js/layout-preview.js'
import {widgetArtMetadata} from '../web/js/widget-art.js'
import {LayoutWidgetPreview, OnroadLayoutPage} from '../web/js/onroad-layout.js'
function chunk(kind, bytes) {
@@ -17,19 +17,28 @@ function image(metadata=meta){
chunk('IDAT',deflateSync(Buffer.from([0,255,255,255,255,0,0,0,0]))),chunk('IEND',Buffer.alloc(0))])],{type:'image/png'})
}
assert.deepEqual(await widgetArtMetadata(image()),meta)
const withHeader={...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-32.5}}}
assert.deepEqual(await widgetArtMetadata(image(withHeader)),withHeader,'Native action sprites include the heading above the buttons')
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:NaN,height:1}}},
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-33}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetY:-32}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetX:1}}}])await assert.rejects(widgetArtMetadata(image(bad)))
await assert.rejects(widgetArtMetadata(new Blob(['not PNG'])))
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0
const feed=new LayoutPreviewFeed({art:true,publish:value=>updates.push(value),
fetcher:(address,options)=>new Promise(resolve=>requests.push({address,options,resolve})),
later:fn=>{timers.set(++next,fn);return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>`blob:${++url}`,revokeObjectURL:value=>revoked.push(value)}})
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()}
const tick=()=>{const [id,fn]=[...timers].at(-1);timers.delete(id);fn()}
const answer=async(index,status=200)=>{requests[index].resolve({status,ok:status===200,
headers:{get:()=> 'image/png'},blob:async()=>image()});await flush()}
function fixture() {
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0,unauthorized=0
const options={art:true,publish:value=>updates.push(value),unauthorized:()=>unauthorized++,
fetcher:(address,options)=>new Promise(resolve=>requests.push({address,options,resolve})),
later:(fn,delay)=>{timers.set(++next,{fn,delay});return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>`blob:${++url}`,revokeObjectURL:value=>revoked.push(value)}}
const feed=new LayoutPreviewFeed(options)
const tick=()=>{const [id,{fn}]=[...timers].at(-1);timers.delete(id);fn()}
const answer=async(index,status=200,blob=image())=>{requests[index].resolve({status,ok:status===200,
headers:{get:()=> 'image/png'},json:async()=>null,blob:async()=>blob});await flush()}
return {feed,options,updates,requests,timers,revoked,tick,answer,get unauthorized(){return unauthorized}}
}
const {feed,updates,requests,timers,revoked,tick,answer}=fixture()
const document={version:6,layouts:{compact:{}},palette:{text:'#FFFFFFFF'}}
feed.start();feed.update(document,'compact','engaged');tick()
assert.equal(requests[0].address,'./api/ui/layout/preview');assert.equal(requests[0].options.credentials,'same-origin')
@@ -38,15 +47,112 @@ await answer(0);assert.deepEqual(updates.at(-1).sprites,meta)
feed.update(document,'compact','engaged',true);assert.equal(feed.currentUrl,'blob:1','Drag retains mounted native art')
assert.equal(feed.pending,null,'Drag queues no native render');assert.equal(feed.timer,null,'Drag schedules no request timer')
assert.equal(requests.length,1,'Entering drag sends no native request')
feed.update(document,'compact','cem_stop_light');assert.equal(feed.currentUrl,null,'Scene change clears stale CEM art')
feed.update(document,'compact','cem_stop_light');assert.equal(feed.currentUrl,'blob:1','Scene change retains art until its replacement arrives')
tick();feed.update(document,'large','cem_curve');assert.equal(requests[1].options.signal.aborted,true)
tick();await answer(1);assert.equal(requests.length,3);await answer(2);assert.equal(updates.at(-1).url,'blob:2')
feed.update(document,'large','braking');tick();await answer(3,503);assert.equal(feed.currentUrl,null)
assert.equal(updates.at(-1).status,'unavailable');feed.stop();assert(revoked.includes('blob:1')&&revoked.includes('blob:2'))
feed.update(document,'compact','engaged')
assert.equal(feed.currentUrl,'blob:1','Returning to a profile restores its artwork immediately')
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0,'Unchanged cached artwork needs no request')
assert.deepEqual(revoked,[],'Switching profiles keeps both mounted sheets alive')
feed.update(document,'large','braking');tick();await answer(3,503)
assert.equal(feed.currentUrl,'blob:2','A transient failure retains visible artwork')
assert.equal(updates.at(-1).status,'updating','One transient failure does not flash the unavailable message')
assert.equal([...timers.values()][0].delay,600,'A transient failure retries promptly')
tick();tick();await answer(4,503)
assert.equal(updates.at(-1).status,'unavailable','Persistent failures still report an outage')
tick()
assert.equal(updates.findLast(update=>update.status)?.status,'unavailable','Retry does not flicker the outage message')
tick();await answer(5)
assert.equal(updates.at(-1).status,'ready');assert.equal(feed.currentUrl,'blob:3')
assert.deepEqual(revoked,[],'Each scene keeps its own sheet')
feed.update(document,'large','cem_curve')
assert.equal(feed.currentUrl,'blob:2','Returning to a rendered scene shows its sheet immediately')
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0)
feed.update(document,'large','aol')
assert.equal(feed.currentUrl,'blob:2','An unrendered scene keeps the sheet on screen instead of outlines')
assert.equal(updates.findLast(update=>'url' in update).scene,'cem_curve')
assert.equal([...timers.values()][0].delay,0,'Scene changes skip the edit debounce')
feed.update(document,'large','braking');assert.equal(feed.currentUrl,'blob:3')
feed.pause();assert.equal(feed.active,false);assert.equal(feed.currentUrl,'blob:3')
feed.start();feed.update(document,'large','braking')
assert.equal(feed.currentUrl,'blob:3');assert.equal(timers.size,0,'Returning to a profile reuses matching artwork')
const edited={...document,palette:{text:'#FF0000FF'}}
feed.update(edited,'large','braking');tick()
assert.equal(feed.currentUrl,'blob:3','Draft edits keep artwork while rendering the new colors')
feed.pause();feed.start();feed.update(document,'compact','engaged');await answer(6)
assert.equal(feed.currentUrl,'blob:1','A late response after switching views cannot replace cached art')
assert.equal(updates.at(-1).status,'ready')
feed.stop();assert.deepEqual(revoked,['blob:1','blob:2','blob:3'])
assert.equal(feed.artCache.size,0);assert.equal(updates.at(-1).url,null)
for(const status of [429,503]){
const cold=fixture();cold.feed.start();cold.feed.update(document,'compact','engaged');cold.tick();await cold.answer(0,status)
assert.equal(cold.updates.at(-1).status,'updating','Cold-start transient failures also retry without flashing a warning')
cold.tick();cold.tick();await cold.answer(1)
assert.equal(cold.updates.some(update=>update.status==='unavailable'),false)
assert.equal(cold.updates.at(-1).status,'ready');cold.feed.stop()
}
for(const status of [401,403]){
const denied=fixture();denied.feed.start();denied.feed.update(document,'compact','engaged');denied.tick();await denied.answer(0)
denied.feed.update(edited,'compact','engaged');denied.tick();await denied.answer(1,status)
assert.equal(denied.feed.currentUrl,null,'Access denial releases retained artwork immediately')
assert.equal(denied.feed.artCache.size,0)
if(status===401){assert.equal(denied.unauthorized,1);assert.equal(denied.feed.active,false)}
else assert.equal(denied.updates.at(-1).status,'unavailable')
denied.feed.stop()
}
const stalled=fixture();stalled.feed.start();stalled.feed.update(document,'compact','engaged');stalled.tick();await stalled.answer(0)
stalled.feed.update(edited,'compact','engaged');stalled.tick();stalled.tick();await flush()
assert.equal(stalled.requests[1].options.signal.aborted,true)
assert.equal(stalled.feed.currentUrl,'blob:1','A timed-out refresh retains artwork')
assert.equal(stalled.updates.at(-1).status,'updating')
stalled.tick();stalled.tick();await stalled.answer(2)
assert.equal(stalled.updates.at(-1).status,'ready','A hung renderer can recover without discarding the last sheet')
stalled.feed.update(document,'compact','engaged');stalled.tick();await stalled.answer(3,200,new Blob(['invalid PNG']))
assert.equal(stalled.feed.currentUrl,'blob:2','Malformed replacement art cannot displace the last good sheet')
stalled.feed.stop()
// Art and backdrop share the device renderer: one request at a time, art first.
{
const queue=new PreviewQueue(),requests=[],timers=new Map();let next=0
const options=extra=>({...extra,queue,publish(){},fetcher:(address,options)=>new Promise(resolve=>requests.push({options,resolve})),
later:(fn,delay)=>{timers.set(++next,{fn,delay});return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>'blob:q',revokeObjectURL(){}}})
const backdrop=new LayoutPreviewFeed(options({backdrop:true})),art=new LayoutPreviewFeed(options({art:true}))
const run=()=>{for(const [id,{fn,delay}] of [...timers])if(delay<=200){timers.delete(id);fn()}}
backdrop.start();art.start();backdrop.update(document,'compact','engaged');art.update(document,'compact','engaged')
run()
await flush();assert.equal(requests.length,1,'The second feed waits instead of being turned away as busy')
assert.equal(JSON.parse(requests[0].options.body).art,'backdrop')
backdrop.update(document,'compact','aol');run()
await flush();assert.equal(requests.length,1,'Cancelling a request does not release the renderer before it answers')
requests[0].resolve({status:200,ok:true,headers:{get:()=>'image/png'},blob:async()=>image()});await flush()
assert.equal(requests.length,2);assert.equal(JSON.parse(requests[1].options.body).art,true,'Widget art goes before the backdrop')
requests[1].resolve({status:200,ok:true,headers:{get:()=>'image/png'},blob:async()=>image()});await flush()
assert.equal(requests.length,3);assert.equal(JSON.parse(requests[2].options.body).scene,'aol')
requests[2].resolve({status:429,ok:false,headers:{get:()=>null},json:async()=>null});await flush()
assert.equal([...timers.values()].at(-1).delay,600,'A busy renderer is retried promptly by the backdrop too')
backdrop.stop();art.stop()
}
// Exercise the real page watchers when changing preview modes and parked authority.
const page=OnroadLayoutPage.setup({mode:'local',projection:false,unauthorized(){}})
Object.assign(page.backdropFeed,{start(){},update(){},stop(){}})
const pageFeed=fixture()
Object.assign(page.artFeed,pageFeed.options,{publish:update=>{Object.assign(page.state.art,update);pageFeed.updates.push(update)}})
page.state.data={editable:true,revision:1,metadata:{profiles:{large:{widgets:{conditional_mode:{}}},compact:{widgets:{conditional_mode:{}}}}}}
page.state.draft=document
pageFeed.tick();await flush();await pageFeed.answer(0) // The page feed takes its turn in the shared render queue first.
const artUrl=page.state.art.url
assert(artUrl)
page.state.data.editable=false
assert.equal(page.artFeed.active,false);assert.equal(page.state.art.url,null,'Parked authority changes clear artwork')
page.artFeed.stop()
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{runtimePreviewWidgets:['conditional_mode']}},'conditional_mode'),true)
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{}},'conditional_mode'),false)
assert.match(LayoutWidgetPreview.template,/data-preview-source="runtime"/)
assert.match(LayoutWidgetPreview.template,/data-preview-source="placeholder"/)
assert.doesNotMatch(LayoutWidgetPreview.template,/scene === 'experimental' \? 'E' : 'C'/)
assert.doesNotMatch(LayoutWidgetPreview.template,/M38 78 Q96 107 154 78/)
console.log('Canonical widget art: metadata bounds, authenticated request, race, drag retention, neutral error, selection passed')
console.log('Canonical widget art: metadata, races, retained refreshes, profile/mode switches, retries, auth and cleanup passed')
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+4 -16
View File
@@ -1383,6 +1383,8 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-step__num { flex: 0 0 auto; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--radius-full);
background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); }
.gx-aa-step--done .gx-aa-step__num { background: var(--success); color: var(--background); }
.gx-aa-home { margin-bottom: 0; }
.gx-aa-step__num.gx-aa-home__icon--live { background: var(--success); color: var(--background); }
.gx-aa-step__body { display: grid; gap: var(--sp-3); min-width: 0; padding-left: calc(36px + var(--sp-4)); }
.gx-aa-step__body > p { margin: 0; line-height: 1.45; }
.gx-aa-step__body > p:has(> .gx-spinner) { display: flex; align-items: center; gap: var(--sp-2); }
@@ -1420,8 +1422,9 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-wiki[open] > summary::before { transform: rotate(90deg); }
.gx-aa-wiki > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-sm); }
.gx-aa-wiki p, .gx-aa-wiki ol, .gx-aa-wiki ul { margin: var(--sp-3) 0 0; }
.gx-aa-wiki ol, .gx-aa-wiki ul { padding-left: 1.3em; display: grid; gap: var(--sp-1); }
.gx-aa-wiki ol, .gx-aa-wiki ul, .gx-aa-mirror-steps { padding-left: 1.3em; display: grid; gap: var(--sp-1); }
.gx-aa-wiki ul { list-style: disc; }
.gx-aa-mirror-steps { margin: 0; }
.gx-aa-faq { margin-top: var(--sp-4); padding: var(--panel-padding); display: grid; gap: var(--sp-3); min-width: 0; overflow-wrap: anywhere; }
.gx-aa-faq__head h3 { margin: 0 0 var(--sp-1); }
.gx-aa-faq__head p { margin: 0; color: var(--on-surface-variant); }
@@ -1473,17 +1476,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-sheet__lead {
align-items: center;
}
.gx-aa-sheet__icon-badge {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
background: var(--primary-container);
color: var(--primary);
display: grid;
place-items: center;
font-size: 1.35rem;
flex-shrink: 0;
}
.gx-aa-sheet__dialog h3 {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
@@ -1584,10 +1576,6 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
font-size: 2.2rem;
box-shadow: 0 0 24px var(--primary-glow);
}
.gx-aa-manage__facts { margin: 0; }
.gx-aa-manage__facts .gx-card { margin: 0; }
.gx-aa-manage__facts dt { font-size: var(--fs-xs); color: var(--on-surface-variant); text-transform: uppercase; letter-spacing: .05em; font-weight: var(--fw-medium); }
.gx-aa-manage__facts dd { margin: 0; font-size: var(--fs-sm); line-height: 1.45; }
.gx-aa-sheet__footer {
gap: var(--sp-1);
+14 -18
View File
@@ -341,18 +341,11 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
.gx-layout__subhead > span, .gx-layout__savebar > span { color: var(--on-surface-variant); }
.gx-layout__zone-toggle { display: flex; align-items: center; gap: 8px; color: var(--on-surface-variant); font-size: var(--fs-sm); }
.gx-layout__zone-toggle input { accent-color: var(--primary); }
.gx-layout__scenes button[aria-pressed="true"] { background: var(--primary-container); color: var(--on-primary-container); outline: 2px solid var(--primary); outline-offset: -2px; }
.gx-layout__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; min-width: 0; }
.gx-layout__preview-card, .gx-layout__inspector, .gx-layout__colors, .gx-layout__discard { padding: 20px; display: grid; gap: 16px; min-width: 0; }
.gx-layout__preview-card { padding: 20px 0; }
.gx-layout__preview-card > :not(svg) { margin-inline: 20px; }
.gx-layout__preview { display: block; width: 100%; height: auto; border-block: 1px solid var(--glass-border); touch-action: none; user-select: none; }
.gx-layout__device { display: grid; gap: 12px; min-width: 0; border-top: 1px solid var(--glass-border); padding-top: 16px; }
.gx-layout__device .gx-layout__scenes { margin-inline: 0; }
.gx-layout__device-frame { display: grid; place-items: center; min-width: 0; width: 100%; overflow: hidden; background: #090c10; color: var(--on-surface-variant); border: 1px solid var(--glass-border); }
.gx-layout__device-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
.gx-layout__device-frame span { padding: 16px; text-align: center; }
.gx-layout__scenes { display: flex; flex-wrap: wrap; gap: 8px; margin-inline: 20px; }
.gx-layout__widget { cursor: grab; outline: none; }
.gx-layout__widget:active { cursor: grabbing; }
.gx-layout-widget-art { pointer-events: none; font-family: var(--font-body); }
@@ -367,6 +360,8 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
.gx-layout__selection { pointer-events: none; opacity: 0; }
.gx-layout__widget.is-selected .gx-layout__selection, .gx-layout__widget:focus-visible .gx-layout__selection { opacity: 1; }
.gx-layout__widget:focus-visible .gx-layout__selection { stroke: #fff; stroke-dasharray: none; }
/* The widget open in Edit; the glow size is in canvas units, so the stage sets it per layout size. */
.gx-layout__widget.is-editing .gx-layout__selection { opacity: 1; stroke-dasharray: none; filter: drop-shadow(0 0 var(--layout-glow) #b799ff); }
.gx-layout__resize { cursor: nwse-resize; touch-action: none; }
.gx-layout__resize:hover circle:last-of-type { fill: #d4c2ff; }
.gx-layout__widget-list { display: grid; gap: 8px; }
@@ -406,9 +401,6 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
.gx-layout__savebar .gx-btn { min-height: 40px; padding: 8px 14px; }
.gx-layout__savebar > span { flex: 1 1 140px; font-size: var(--fs-sm); }
.gx-layout__help > div { display: grid; gap: 10px; padding-block: 8px; }
.gx-layout__device { border: 0; padding-top: 0; }
.gx-layout__scenes { flex-wrap: nowrap; overflow-x: auto; padding: 2px; }
.gx-layout__scenes > button { flex-shrink: 0; }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
@container (max-width: 1040px) {
.gx-layout { max-width: 760px; }
@@ -451,8 +443,6 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
.gx-layout__drag-ghost { position: fixed; z-index: 1000; pointer-events: none; display: flex; align-items: center; gap: 12px; padding: 12px 16px; min-height: 44px; box-sizing: border-box; border: 2px solid var(--primary); border-radius: 16px; background: var(--background); color: var(--on-surface); box-shadow: 0 12px 32px #0008; transform: scale(1.03); }
.gx-layout__drag-ghost strong { flex: 1; }
.gx-layout__outline { pointer-events: none; }
.gx-layout__device-frame { position: relative; }
.gx-layout__device-outline { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
.gx-layout__help > summary { cursor: pointer; min-height: 40px; align-content: center; }
.gx-layout__layer.is-dragging { opacity: .45; }
@@ -513,10 +503,8 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
.gx-layout__menu-item:disabled { opacity: .45; cursor: default; }
.gx-layout__preview-bar { display: flex; align-items: center; gap: 6px; min-width: 0; }
.gx-layout__preview-bar > .gx-layout__switcher { flex: 1; }
.gx-layout__bar-toggle { flex: none; color: var(--on-surface-variant); }
.gx-layout__tips { display: grid; gap: 10px; max-height: 30vh; overflow-y: auto; overscroll-behavior: contain; }
.gx-layout__device-stop { position: absolute; top: 8px; right: 8px; z-index: 2; width: 36px; height: 36px; min-width: 36px; min-height: 36px; background: rgba(0, 0, 0, .6); color: #fff; }
.gx-layout__picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.gx-layout__coordinates { grid-template-columns: repeat(auto-fit, minmax(68px, 1fr)); gap: 8px; }
@@ -548,9 +536,8 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
/* Narrow editors pin the save bar, preview and editor tabs; controls scroll underneath. */
@container (max-width: 1040px) {
.gx-layout__preview-card { position: sticky; top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px)); z-index: 6; padding: 8px; gap: 6px; margin-bottom: 0; background: var(--layout-solid); }
.gx-layout__preview-card > .gx-layout__subhead, .gx-layout__device > .gx-layout__subhead { display: none; }
.gx-layout__preview-card .gx-layout__preview, .gx-layout__device-frame { width: 100%; max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__device { gap: 8px; }
.gx-layout__preview-card > .gx-layout__subhead { display: none; }
.gx-layout__preview-card .gx-layout__preview { width: 100%; max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__sections { position: sticky; top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px) + var(--layout-pin-h, 0px)); z-index: 5;
margin: -14px -14px 0; padding: 10px 14px; background: var(--layout-solid); border-bottom: 1px solid var(--glass-border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.gx-layout__widget-list { gap: 6px; }
@@ -572,7 +559,7 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
@media (orientation: landscape) and (max-height: 560px) {
.gx-layout { max-width: none; --layout-pin-max: calc(100vh - var(--layout-sticky-top, 0px) - var(--layout-bar-h, 60px) - 76px); --layout-pin-max: calc(100svh - var(--layout-sticky-top, 0px) - var(--layout-bar-h, 60px) - 76px); }
.gx-layout__workspace { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); }
.gx-layout__preview-card .gx-layout__preview, .gx-layout__device-frame { max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__preview-card .gx-layout__preview { max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__sections { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px)); }
}
@@ -656,6 +643,15 @@ input[type="range"].gx-slider:active::-moz-range-thumb {
/* Tap-area overlay label sits on the preview while the grid toggle is on. */
.gx-layout__preview-card > .gx-layout__stage { position: relative; margin-inline: 0; min-width: 0; }
.gx-layout__stage-chip { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid #b799ff88; border-radius: var(--radius-full); background: rgba(22, 18, 31, .82); color: #d8c8ff; font-size: var(--fs-xs); font-weight: var(--fw-bold); white-space: nowrap; pointer-events: none; }
.gx-layout__stage-chip--below { position: static; justify-self: end; }
.gx-layout__stage-chip--start { right: auto; left: 6px; max-width: calc(100% - 12px); overflow: hidden; text-overflow: ellipsis; display: block; }
/* Covers the whole bar, buttons included, so its size never changes mid-drag. */
.gx-layout__savebar-snap { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; gap: 6px; padding: 0 14px; border-radius: inherit; background: inherit; color: var(--primary); font-weight: var(--fw-bold); pointer-events: none; }
.gx-layout__savebar-snap > .bi { flex: none; }
.gx-layout__stage-chip--with-tap-areas { max-width: calc(100% - 120px); }
.gx-layout__guide { stroke: #b799ff; stroke-width: 1.5; }
.gx-layout__guide--center { stroke-dasharray: 6 4; }
.gx-layout__guide--bounds, .gx-layout__guide--zone { stroke: #8495a8; }
/* On narrow editors a pinned save bar also covers the strip behind the app bar, so the editor never scrolls up
behind the search. The fill stays inside the editor column and follows the bar's rounded corners. Its outline is
@@ -89,7 +89,7 @@
"name": "Android Auto",
"path": "/android-auto",
"icon": "bi-phone",
"description": "Phone projection and connection settings",
"description": "Show StarPilot on your car’s screen",
"availability": "local-only",
"visibility": "authenticated"
}
@@ -316,13 +316,14 @@ export const APKMIRROR_URL = "https://www.apkmirror.com/apk/google-inc/android-a
export const DOWNLOAD_LINK = /^https:\/\/download\.pureapk\.com\/b\/X?APK\/[!-~]+$/
const WRONG_DOWNLOAD = "That’s not the Android Auto download. Download it again in step 1."
export function downloadLink(release) {
// While the comma is still trying APKPure, wait instead of sending people to APKMirror.
export function downloadLink(release, pending = false) {
if (typeof release?.url === "string" && DOWNLOAD_LINK.test(release.url) && typeof release.version === "string" &&
release.version.length <= 128 && /^[0-9]+(?:\.[0-9]+)+-release$/.test(release.version) &&
Number.isSafeInteger(release.size) && release.size > 0) {
release.version.length <= 128 && /^[0-9]+(?:\.[0-9]+)+-release$/.test(release.version) &&
Number.isSafeInteger(release.size) && release.size > 0) {
return { direct: true, url: release.url, version: release.version.replace(/-release$/, ""), megabytes: Math.ceil(release.size / 1048576) }
}
return { direct: false, url: APKMIRROR_URL }
return pending ? { direct: false, pending: true, url: "" } : { direct: false, url: APKMIRROR_URL }
}
const STAGES = ["reading", "searching", "decrypting", "verifying", "installing", "done"]
const CHECKS = [
@@ -421,7 +422,7 @@ export const AndroidAutoPage = {
setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, removeOpen: false, installAttempted: false, installBaseline: null,
downloadStarted: false, packageMagicError: "", packageChecking: false, packageSelection: 0,
installFileName: "", pairWhenReady: false, enableOpen: false
installFileName: "", pairWhenReady: false, enableOpen: false, showSteps: false
}),
mounted() {
this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
@@ -447,8 +448,14 @@ export const AndroidAutoPage = {
canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) },
expiry() { return expiryInfo(this.setup?.identity) },
projecting() { return this.runtime?.state === "streaming" },
installedVersion() {
const version = this.setup?.identity.installed && this.setup.identity.app_version
return typeof version === "string" ? version.replace(/-release.*$/, "") : ""
},
supportReady() { return !!(this.setup?.enabled && this.setup.identity.installed) },
download() { return downloadLink(this.setup?.download) },
// Returning drivers see a status card; the setup steps fold away below it.
setupDone() { return !!(this.supportReady && this.selected && !this.pairing?.active) },
download() { return downloadLink(this.setup?.download, this.setup?.downloadPending === true) },
uploadPercent() {
const { loaded, total } = this.uploadProgress || {}
return total > 0 ? Math.min(100, Math.round(100 * loaded / total)) : 0
@@ -635,7 +642,27 @@ export const AndroidAutoPage = {
<GxNotice tone="warn">Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.</GxNotice>
<GxNotice tone="danger" v-if="error && setup">{{ error }}</GxNotice>
<GxNotice v-if="!setup" :busy="!error" :tone="error ? 'danger' : 'info'">{{ error || "Checking Android Auto setup… Reconnecting automatically…" }}</GxNotice>
<ol v-else class="gx-aa__steps">
<section v-if="setup && setupDone" class="gx-card gx-aa-step gx-aa-home" aria-labelledby="gx-aa-home-title">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" :class="{ 'gx-aa-home__icon--live': projecting }" aria-hidden="true"><i class="bi bi-car-front"></i></span>
<div><h3 id="gx-aa-home-title">{{ projecting ? 'Connected to ' + selected.name : runtime?.running ? 'Connecting to ' + selected.name : selected.name }}</h3>
<p role="status">{{ runtime?.running && runtime.state !== 'streaming' && runtime.label ? runtime.label : runtime?.running ? 'Showing StarPilot on your car’s screen.' : runtime?.auto_connect ? 'Connects automatically when you start driving.' : 'Ready to connect.' }}</p></div>
</div>
<div class="gx-aa-step__body">
<p v-if="expiry.level === 'soon'" class="gx-aa-pill gx-aa-pill--soon" role="status">{{ expiry.label }}</p>
<button v-if="!runtime?.running" class="gx-btn gx-aa-cta" :disabled="!!connectReason" @click="connect">Connect</button>
<button v-else class="gx-btn gx-btn--tonal gx-aa-cta" @click="control('stop')">Disconnect</button>
<GalaxySettingRow :row="switchRow('Automatic Connection', runtime?.auto_connect, 'Connect to your car whenever you start driving.')"
:index="0" :busy="busy" :disabled="!setup.enabled || !setup.parked || !!pairing?.active"
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
<div class="gx-actions">
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/android_auto"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-aa-steps" @click="showSteps = !showSteps">
<i :class="showSteps ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSteps ? 'Hide Setup' : 'Setup and Pairing' }}</button>
</div>
</div>
</section>
<ol v-if="setup" v-show="!setupDone || showSteps" id="gx-aa-steps" class="gx-aa__steps">
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': supportReady }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="supportReady" class="bi bi-check-lg"></i><template v-else>1</template></span>
@@ -645,17 +672,17 @@ export const AndroidAutoPage = {
<p v-if="!setup.installReady" class="gx-aa-pill gx-aa-pill--expired" role="status">Not available on this build — the Android Auto display and encoder are missing.</p>
<template v-else>
<div class="gx-aa-status">
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}<template v-if="hasPackage && !setup.enabled"> · Off</template></span>
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}</span>
<button v-if="hasPackage" type="button" class="gx-icon-btn gx-recordings__danger" aria-label="Delete Package" :disabled="!!removeReason" :title="removeReason || 'Delete Package'" @click="openRemove"><i class="bi bi-trash3"></i></button>
<span v-if="setup.enabled && !setup.serviceReady" class="gx-note" role="status">Starting…</span>
<div class="gx-actions gx-aa-status__actions">
<button v-if="!setup.identity.installed" class="gx-btn" @click="openInstall">{{ setup.identity.expired || setup.identity.error ? 'Reinstall' : 'Install support' }}</button>
<button v-else-if="expiry.level === 'soon'" class="gx-btn" @click="openInstall">Update</button>
<button v-if="setup.identity.installed" class="gx-btn gx-btn--tonal" @click="openInstall">Manage package</button>
<button v-if="setup.identity.installed && !setup.enabled" class="gx-btn" @click="setEnabled(true)">Turn On</button>
<button v-if="setup.enabled" class="gx-btn gx-btn--tonal" @click="setEnabled(false)">Turn Off</button>
<button v-else class="gx-btn gx-btn--tonal" @click="openInstall">Install newer version</button>
</div>
</div>
<GalaxySettingRow :row="switchRow('Android Auto', setup.enabled, 'Turn off to stop connecting to your car. Your setup and paired car stay saved.')"
:index="0" :busy="busy" :disabled="enableOpen" :save-value="(index, value) => setEnabled(value === 'On')" />
<p v-if="setup.import?.state === 'running' && !installOpen" class="gx-note" role="status">Checking your package…</p>
</template>
<details class="gx-aa-wiki"><summary>Stuck installing? Common problems</summary>
@@ -726,7 +753,7 @@ export const AndroidAutoPage = {
<details class="gx-aa-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
<ul>
<li><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</li>
<li><strong>Cars with built-in wireless Android Auto:</strong> Set the car into pairing (search) mode. On Comma, go to Settings -> Bluetooth -> Scan For Devices. Choose your car, then confirm the code on both screens.</li>
<li><strong>Cars with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, then tap <strong>Find car</strong> above. Choose your car, then confirm the code on both screens.</li>
<li><strong>Wireless adapter</strong> (AAWireless, Motorola MA1, Carlinkit…): pair your car in <a href="#/bluetooth">Bluetooth</a> first so calls keep working, then put the adapter in pairing mode and pair it here. Adapter names often look like AndroidAuto-XXXX.</li>
</ul>
<p><strong>Start fresh:</strong></p>
@@ -774,7 +801,9 @@ export const AndroidAutoPage = {
<div><h3>Customize Your Dashboard</h3><p>Once connected, customize your dashboard setup.</p></div>
</div>
<div class="gx-aa-step__body">
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/theme_maker/android_auto">Customize Dashboard</a>
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/theme_maker/android_auto">Customize Layout</a>
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/navigation/maps"><i class="bi bi-map" aria-hidden="true"></i> Offline Road Maps</a>
<p class="gx-note">Save road lines for the map overlay so it keeps drawing roads with no signal.</p>
</div>
</li>
</ol>
@@ -921,32 +950,13 @@ export const AndroidAutoPage = {
<ul class="gx-aa-checks"><li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li></ul>
<button class="gx-btn gx-aa-cta" @click="closeInstall">Done</button>
</div>
<div v-else-if="setup.identity.installed && !installAttempted" class="gx-stack">
<div class="gx-stack gx-aa-sheet__head">
<div class="gx-cluster gx-aa-sheet__lead">
<div class="gx-aa-sheet__icon-badge"><i class="bi bi-shield-check"></i></div>
<div>
<h3 id="gx-aa-install-title">Android Auto Support</h3>
<p class="gx-note">Support is set up on this comma. To set it up again, remove it first.</p>
</div>
</div>
</div>
<dl class="gx-stack gx-aa-manage__facts">
<div class="gx-card gx-info-card">
<dt>Status</dt>
<dd><span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level">{{ expiry.label }}</span></dd>
</div>
</dl>
<div class="gx-actions">
<button type="button" class="gx-btn gx-btn--danger" :disabled="!!removeReason" :title="removeReason || undefined" @click="closeInstall(); openRemove()"><i class="bi bi-trash3" aria-hidden="true"></i> Remove Android Auto support</button>
</div>
</div>
<div v-else class="gx-stack">
<div class="gx-stack gx-aa-sheet__head">
<div class="gx-cluster gx-aa-sheet__lead">
<div>
<h3 id="gx-aa-install-title">Set Up Android Auto Support</h3>
<p class="gx-note">One-time setup so your car accepts your comma.</p>
<h3 id="gx-aa-install-title">{{ setup.identity.installed ? 'Install a Newer Version' : 'Set Up Android Auto Support' }}</h3>
<p v-if="setup.identity.installed" class="gx-note"><template v-if="installedVersion">Current version: {{ installedVersion }} · </template>{{ expiry.label }}. Your paired car and settings stay as they are.</p>
<p v-else class="gx-note">One-time setup so your car accepts your comma.</p>
</div>
</div>
</div>
@@ -956,16 +966,26 @@ export const AndroidAutoPage = {
<span class="gx-aa-step__num gx-aa-step-card__num">1</span>
<div class="gx-aa-step-card__title">
<strong>Download Android Auto</strong>
<small>{{ download.direct ? 'Saves the app to this ' + (onPhone ? 'phone' : 'computer') : 'Get the newest release from APKMirror' }}</small>
<small>{{ download.direct ? 'Saves the app to this ' + (onPhone ? 'phone' : 'computer') : download.pending ? 'Looking up the newest release…' : 'Get the newest release from APKMirror' }}<template v-if="installedVersion"> · You have {{ installedVersion }}</template></small>
</div>
</div>
<div class="gx-aa-step-card__body">
<a v-if="download.direct" class="gx-btn gx-btn--tonal" :href="download.url" rel="noreferrer" download @click="markDownloadStarted">
<i class="bi bi-download" aria-hidden="true"></i> Download Android Auto {{ download.version }} · {{ download.megabytes }} MB
</a>
<button v-else-if="download.pending" class="gx-btn gx-btn--tonal" type="button" disabled>
<i class="bi bi-hourglass-split" aria-hidden="true"></i> Finding the newest Android Auto…
</button>
<a v-else class="gx-btn gx-btn--tonal" :href="download.url" target="_blank" rel="noopener noreferrer" @click="markDownloadStarted">
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Open APKMirror
</a>
<ol v-if="!download.direct && !download.pending" class="gx-note gx-aa-mirror-steps">
<li>Open <strong>Android Auto</strong> on APKMirror, then open the <strong>All versions</strong> list and click through the versions to compare their release dates.</li>
<li>Open the version with the <strong>newest release date</strong>.</li>
<li>Scroll down to <strong>Available downloads</strong>. Under <strong>Variant</strong>, click the <strong>17.x.xxxx‑release</strong> or <strong>18.x.xxxx‑release</strong> entry.</li>
<li>Click <strong>Download APK Bundle</strong> (or <strong>Download APK</strong>). You get an <strong>.apk</strong>, <strong>.xapk</strong> or <strong>.apkm</strong> file. Leave it zipped.</li>
</ol>
<small v-if="!download.direct && !download.pending" class="gx-note">You don’t need the APKMirror Installer app.</small>
<small v-if="download.direct" class="gx-note">From APKPure, a third-party mirror (<a class="gx-link" href="https://apkpure.com/terms.html" target="_blank" rel="noopener noreferrer">terms</a>). Don’t unzip it.</small>
</div>
</li>
+8 -1
View File
@@ -3,6 +3,7 @@ import { toggleHaptic } from "./haptics.js"
import { GxState } from "./state.js"
import { BottomNav, NAV, primaryTab } from "./bottom-nav.js"
import { UpdateRestart, UPDATE_RESTART_MESSAGE } from "./update-restart.js"
import { DeviceRestartWatch } from "./device-restart.js"
import { GalaxyLoading } from "./loading-screen.js"
import { GxNotice } from "./notice.js"
import { createApp, reactive, defineAsyncComponent } from "../vendor/vue/vue.esm-browser.js"
@@ -69,6 +70,7 @@ const state = reactive({
searchPage: "",
})
const updateRestart = new UpdateRestart({ publish: waiting => { state.updateRestarting = waiting } })
const deviceRestart = new DeviceRestartWatch({ paused: () => state.updateRestarting })
const authState = reactive({ status: "checking", error: "", localAccess: false, gatewayAccess: false })
const authForm = reactive({ password: "" })
const auth = new LocalAuth({ publish: (update) => {
@@ -143,11 +145,13 @@ createApp({
mounted() {
document.documentElement.dataset.theme = state.theme
this.visibility = () => {
if (!document.hidden) deviceRestart.wake()
clearTimeout(reconnectTimer)
reconnectTimer = null
if (!document.hidden && (state.error || authState.status === "unavailable") && !initializing) initialize()
}
document.addEventListener("visibilitychange", this.visibility)
window.addEventListener("pageshow", this.visibility)
setRouteLeaveGuard((proceed, { back } = {}) => {
if (back && this.$refs.activeSettings?.navigateBack()) return
if (back && state.searchPage) { this.returnFromSearch(); return }
@@ -163,6 +167,8 @@ createApp({
clearTimeout(reconnectTimer)
startupGeneration++
document.removeEventListener("visibilitychange", this.visibility)
window.removeEventListener("pageshow", this.visibility)
deviceRestart.stop()
},
template: `
<div class="gx-app" :class="{'gx-nav-pinned':state.navPinned}" @change.capture="toggleHaptic">
@@ -223,7 +229,7 @@ createApp({
<OnroadLayoutPage ref="activeLayout" v-else-if="['/theme_maker', '/theme_maker/android_auto', '/appearance'].includes(route.path)" :key="route.path" :projection="route.path === '/theme_maker/android_auto'" :initial-panel="route.path === '/appearance' ? 'display' : 'widgets'" :mode="state.monitorMode" :unauthorized="sessionExpired" @target="go($event === 'projection' ? '/theme_maker/android_auto' : '/theme_maker')" @close="routeBack" />
<SettingsRecoveryPage v-else-if="route.path === '/settings/recovery'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<Logs v-else-if="route.path === '/logs' || route.path.startsWith('/logs/') || ['/troubleshoot', '/manage_tmux'].includes(route.path)" :path="route.path === '/troubleshoot' ? '/logs/troubleshoot' : route.path === '/manage_tmux' ? '/logs/tmux' : route.path" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" :restarting="baseline => updateRestart.start(baseline)" />
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" :restarting="baseline => updateRestart.start({ ...baseline, instance: deviceRestart.instance })" />
<NavigationPage v-else-if="['/navigation', '/navigation/maps', '/navigation/setup'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go"
:initial-tab="route.path === '/navigation/maps' ? 'maps' : route.path === '/navigation/setup' ? 'setup' : 'route'" />
<ModelsPage v-else-if="route.path === '/manage_models'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
@@ -282,6 +288,7 @@ async function initialize() {
const catalog = await loadCatalog()
if (generation !== startupGeneration) return
state.monitorMode = catalog.mode
deviceRestart.track(catalog.instance)
state.tools = catalog.tools
state.error = ""
if (state.monitorMode === "local") await auth.check()
+15 -1
View File
@@ -1,11 +1,25 @@
import { savedUpdateRestart, UPDATE_RESTART_MESSAGE } from "./update-restart.js"
import { requestJson } from "./startup.js"
const status = document.getElementById("galaxy-boot")
const message = status.querySelector("p")
const retry = status.querySelector("button")
if (savedUpdateRestart()) { message.textContent = UPDATE_RESTART_MESSAGE; retry.textContent = "↻ Refresh Galaxy"; retry.hidden = false }
retry.addEventListener("click", () => location.reload())
let probe = null, sawOffline = false, opened = false
function reloadWhenBack() {
if (probe !== null || opened) return
probe = setTimeout(() => requestJson("./data/runtime.json")
.then(() => true, () => false)
.then(online => {
probe = null
if (opened) return
if (online && sawOffline) location.reload()
else { sawOffline ||= !online; reloadWhenBack() }
}), 3000)
}
function failed() {
reloadWhenBack()
if (savedUpdateRestart()) return
message.textContent = "Galaxy could not finish opening. Check your connection and try again."
status.classList.add("gx-boot--failed")
@@ -23,6 +37,6 @@ const timer = setTimeout(failed, 10000)
import("./connection-migration.js").then(module => module.retireLegacyPhone()).catch(() => {})
import("./app.js").then(() => {
clearTimeout(timer)
if (document.querySelector("#galaxy-app .gx-app")) dismiss()
if (document.querySelector("#galaxy-app .gx-app")) { opened = true; clearTimeout(probe); probe = null; dismiss() }
else failed()
}).catch(() => { clearTimeout(timer); failed() })
@@ -0,0 +1,58 @@
import { requestJson } from "./startup.js"
export class DeviceRestartWatch {
constructor({ request = requestJson, interval = 5000, later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id),
reload = () => location.reload(), hidden = () => typeof document !== "undefined" && document.hidden, paused = () => false }) {
Object.assign(this, { request, interval, later, cancel, reload, hidden, paused })
this.instance = null
this.timer = null
this.checking = false
this.reloading = false
}
track(instance) {
if (typeof instance !== "string" || !instance) return
if (this.instance === null) {
this.instance = instance
this.schedule()
} else if (instance !== this.instance) this.restarted()
}
restarted() {
if (this.reloading || this.paused()) return
this.reloading = true
this.stop()
this.reload()
}
schedule() {
if (this.instance === null || this.reloading || this.timer !== null) return
this.timer = this.later(() => { this.timer = null; this.check() }, this.interval)
}
wake() {
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
this.check()
}
async check() {
if (this.instance === null || this.checking || this.reloading) return
if (this.hidden()) { this.schedule(); return }
this.checking = true
try {
const runtime = await this.request("./data/runtime.json")
this.track(runtime?.instance)
} catch {
// Offline while rebooting; keep checking until Galaxy answers again.
} finally {
this.checking = false
this.schedule()
}
}
stop() {
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
}
}
@@ -1,5 +1,9 @@
import { widgetArtMetadata } from "./widget-art.js"
import { widgetSize } from "./onroad-layout-document.js"
// hiddenOn: screens where the scene looks exactly like another one. The Small screen has no text for these
// speed-limit states, so they match Engaged there.
export const PREVIEW_SCENES = [
{ id: "general", label: "General edit" },
{ id: "engaged", label: "Engaged" },
{ id: "aol", label: "Lateral only" },
{ id: "long_only", label: "Longitudinal only" },
@@ -12,28 +16,97 @@ export const PREVIEW_SCENES = [
{ id: "slc_pending", label: "Pending speed limit" },
{ id: "slc_max", label: "Using set speed" },
{ id: "slc_equal", label: "Equal speed limits" },
{ id: "slc_pedal", label: "Using accelerator" },
{ id: "slc_retained", label: "Set-speed override" },
{ id: "slc_pedal", label: "Using accelerator", hiddenOn: ["compact"] },
{ id: "slc_retained", label: "Set-speed override", hiddenOn: ["compact"] },
{ id: "slc_disengaged", label: "Cruise off" },
{ id: "slc_unavailable", label: "Unavailable speed limit" },
{ id: "slc_display", label: "Display-only speed limit" },
{ id: "slc_display", label: "Display-only speed limit", hiddenOn: ["compact"] },
]
const SCENES = PREVIEW_SCENES.map(({ id }) => id)
// Keep the sketch and its eye controls aligned with sample_state and the native onroad view.
export function previewScene(scene) {
const off = scene === "slc_disengaged"
const lateral = !off && scene !== "long_only"
const longitudinal = !off && scene !== "aol"
const experimental = scene === "experimental" || scene.startsWith("cem_")
return { lateral, longitudinal, experimental,
border: off ? "#122839" : experimental ? "#da6f25" : !longitudinal ? "#0abab5" : !lateral ? "#ff69b4" : "#167f40",
mode: off ? "Disengaged" : !longitudinal ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill" }
}
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}) {
if (scene === "general") return false
const { lateral, longitudinal } = previewScene(scene)
if (id === "stopped_timer") return scene !== "stopped"
if (id === "cem_gauge") return !scene.startsWith("cem_")
if (id === "torque_bar" || profile === "compact" && id === "steering_wheel") return !lateral
if (profile === "compact" && id === "max_speed") return !longitudinal
if (id === "speed_limit_actions") return !longitudinal || scene === "slc_unavailable" || scene === "slc_display" ||
profile === "compact" && scene !== "slc_pending"
const overlaps = (a, b) => a && b && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
const box = key => {
if (!layout[key]?.enabled || !widgets[key]) return null
const [width, height] = widgetSize(widgets[key], layout[key])
return { ...widgets[key], ...layout[key], width, height }
}
if (profile === "compact" && id === "driver_monitor" && longitudinal) return !!overlaps(box(id), box("max_speed"))
if (profile === "compact" && id === "speed_limit") {
if (scene === "slc_unavailable") return true
const actions = box("speed_limit_actions")
if (scene !== "slc_pending" || !actions) return false
const inset = actions.visualInsetTop || 0
return !!overlaps(box(id), { ...actions, y: actions.y >= inset ? actions.y - inset : actions.y, height: actions.height + inset })
}
return false
}
// The device renders one preview at a time and answers a second request with 429.
// Feeds sharing a queue take turns instead, widget art before the backdrop.
export class PreviewQueue {
constructor() { this.running = false; this.waiting = [] }
acquire(priority, signal) {
return new Promise((resolve, reject) => {
const cancel = () => { this.waiting = this.waiting.filter(item => item !== entry); reject(new Error("Preview request cancelled")) }
const entry = { priority, start: () => { signal.removeEventListener("abort", cancel); resolve() } }
if (signal.aborted) { reject(new Error("Preview request cancelled")); return }
signal.addEventListener("abort", cancel, { once: true })
this.waiting.push(entry)
this.waiting.sort((a, b) => a.priority - b.priority)
this.next()
})
}
next() {
if (this.running || !this.waiting.length) return
this.running = true
this.waiting.shift().start()
}
release() { this.running = false; this.next() }
}
export class LayoutPreviewFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id),
urls = URL, art = false }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art })
urls = URL, art = false, backdrop = false, queue = null }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art, backdrop, queue })
this.active = false
this.version = 0
this.timer = null
this.request = null
this.currentUrl = null
this.pending = null
// Keep one native sheet per profile and scene, so returning to either is instant.
this.artCache = new Map()
// The sheet last shown for a profile stands in for a scene that has not rendered yet.
this.latestArt = new Map()
this.failureKey = null
this.failures = 0
}
stop() {
pause() {
this.active = false
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
@@ -42,38 +115,67 @@ export class LayoutPreviewFeed {
this.inputKey = null
this.request?.abort()
this.request = null
this.last = null
}
stop() {
this.pause()
this.clearImage()
this.failureKey = null
this.failures = 0
}
clearImage() {
if (this.currentUrl) this.urls.revokeObjectURL(this.currentUrl)
const urls = new Set([...this.artCache.values()].map(item => item.url))
if (this.currentUrl) urls.add(this.currentUrl)
for (const url of urls) this.urls.revokeObjectURL(url)
this.artCache.clear()
this.latestArt.clear()
this.currentUrl = null
this.publish({ url: null, ...(this.art ? {sprites: null} : {}) })
}
start() { this.stop(); this.active = true }
start() { if (this.art) this.pause(); else this.stop(); this.active = true }
update(document, profile, scene, dragging = false) {
// `key` stands in for the document when only part of it changes the image (the backdrop ignores positions).
update(document, profile, scene, dragging = false, key = document) {
if (!this.active || !document || !["large", "compact", "projection"].includes(profile) || !SCENES.includes(scene)) return
const inputKey = JSON.stringify({ document, profile, scene, dragging })
const documentKey = JSON.stringify(key)
const inputKey = JSON.stringify([documentKey, profile, scene, dragging])
if (inputKey === this.inputKey) return
this.inputKey = inputKey
// Picking a scene or screen is one discrete change, so only document edits are debounced.
const debounce = documentKey === this.documentKey ? 0 : 200
this.documentKey = documentKey
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.pending = null
this.request?.abort()
if (dragging) { this.publish({ status: "editing", error: "" }); return }
if (this.art) this.clearImage()
this.pending = { document: JSON.parse(JSON.stringify(document)), profile, scene, version: this.version }
this.publish({ status: "updating" })
this.timer = this.later(() => { this.timer = null; this.dispatch() }, 200)
const renderKey = JSON.stringify([documentKey, profile, scene])
if (renderKey !== this.failureKey) { this.failureKey = renderKey; this.failures = 0 }
if (this.art) {
const cacheKey = profile + "\n" + scene, cached = this.artCache.get(cacheKey)
if (cached) this.latestArt.set(profile, cacheKey)
const shown = cached || this.artCache.get(this.latestArt.get(profile))
this.currentUrl = shown?.url || null
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null })
if (cached?.key === renderKey) {
this.publish({ status: "ready", error: "" })
return
}
}
this.pending = { document: JSON.parse(JSON.stringify(document)), key, profile, scene, version: this.version, renderKey }
// Once a real outage is reported, keep the message stable through retries.
if (!this.art || this.failures < 2) this.publish({ status: "updating", error: "" })
this.timer = this.later(() => { this.timer = null; this.dispatch() }, debounce)
}
retry() {
if (!this.active || !this.last) return
this.inputKey = null
this.update(this.last.document, this.last.profile, this.last.scene)
this.update(this.last.document, this.last.profile, this.last.scene, false, this.last.key)
}
async dispatch() {
@@ -88,15 +190,17 @@ export class LayoutPreviewFeed {
const aborted = new Promise((_, reject) => { rejectAborted = () => reject(new Error("Preview request cancelled")) })
request.signal.addEventListener("abort", rejectAborted, { once: true })
let failure = "Preview is unavailable. Reconnecting automatically…"
try {
await Promise.race([aborted, (async () => {
let denied = false, busy = false
const work = (async () => {
const held = !!this.queue && await this.queue.acquire(this.art ? 0 : 1, request.signal).then(() => true)
try {
const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin",
cache: "no-store", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : {}) }), signal: request.signal })
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : this.backdrop ? {art: "backdrop"} : {}) }), signal: request.signal })
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 403) { failure = "Turn off the vehicle to preview this layout."; throw new Error() }
if (response.status === 429) { failure = "Preview is busy. Retrying automatically…"; throw new Error() }
if (response.status === 403) { denied = true; failure = "Turn off the vehicle to preview this layout."; throw new Error() }
if (response.status === 429) { busy = true; failure = "Preview is busy. Retrying automatically…"; throw new Error() }
if (!response.ok) { const detail = await response.json().catch(() => null); failure = detail?.error || failure; throw new Error() }
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("image/png")) throw new Error("Preview is unavailable. Reconnecting automatically…")
const blob = await response.blob()
@@ -104,17 +208,33 @@ export class LayoutPreviewFeed {
if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Reconnecting automatically…")
const sprites = this.art ? await widgetArtMetadata(blob) : null
if (!this.active || item.version !== this.version || request.signal.aborted) return
const previousUrl = this.currentUrl
const cacheKey = item.profile + "\n" + item.scene
const previousUrl = this.art ? this.artCache.get(cacheKey)?.url : this.currentUrl
this.currentUrl = this.urls.createObjectURL(blob)
this.publish({ status: "ready", error: "", url: this.currentUrl, ...(this.art ? {sprites} : {}) })
if (this.art) {
this.artCache.set(cacheKey, { key: item.renderKey, url: this.currentUrl, sprites, scene: item.scene })
this.latestArt.set(item.profile, cacheKey)
}
this.failures = 0
this.publish({ status: "ready", error: "", url: this.currentUrl, scene: item.scene, profile: item.profile, ...(this.art ? {sprites} : {}) })
if (previousUrl) this.urls.revokeObjectURL(previousUrl)
})()])
} finally {
// Hold the queue until the device answers, so the other feed is not turned away as busy.
if (held) this.queue.release()
}
})()
work.catch(() => {}) // A cancelled request settles after the race below has moved on.
try {
await Promise.race([aborted, work])
} catch {
if (this.active && item.version === this.version) {
this.inputKey = null
if (this.art) this.clearImage()
this.publish({ status: "unavailable", error: failure })
this.timer = this.later(() => { this.timer = null; this.retry() }, 5000)
this.failures = denied ? 2 : this.failures + 1
if (this.art && denied) this.clearImage()
// Art retries promptly, as does any feed turned away by a busy renderer; real outages back off.
const recovering = (this.art || busy) && this.failures < 2
this.publish({ status: recovering ? "updating" : "unavailable", error: recovering ? "" : failure })
this.timer = this.later(() => { this.timer = null; this.retry() }, recovering ? 600 : 5000)
}
} finally {
this.cancelTimer(timeout)
@@ -0,0 +1,75 @@
import { widgetSize } from "./onroad-layout-document.js"
export const SNAP_DISTANCE = { touch: 10, fine: 6 }
const NON_TARGET_KINDS = ["torque_bar"]
const PRIORITY = { center: 0, bounds: 1, widget: 2, zone: 3 }
const EPSILON = 1e-6
function lines(box, axis) {
const start = box[axis], size = box[axis === "x" ? "width" : "height"]
return { start, center: start + size / 2, end: start + size }
}
export function snapTargets(profile, layout, id, visible) {
if (!Object.hasOwn(profile.widgets, id)) return []
const targets = []
const add = (box, kind, label, names) => {
for (const axis of ["x", "y"]) {
const values = lines(box, axis)
// Keep the line role so widget centers cannot attract moving edges.
for (const line of names) targets.push({ axis, value: values[line], kind, label, line })
}
}
const bounds = profile.widgets[id].bounds || profile.bounds
add(bounds, "bounds", "the edge", ["start", "end"])
add(bounds, "center", "the center", ["center"])
for (const [key, widget] of Object.entries(profile.widgets)) {
const at = layout[key]
if (key === id || !at?.enabled || !Number.isFinite(at.x) || !Number.isFinite(at.y) ||
!visible(key) || NON_TARGET_KINDS.includes(widget.kind)) continue
const [width, height] = widgetSize(widget, at)
add({ x: at.x, y: at.y, width, height }, "widget", widget.label, ["start", "center", "end"])
}
for (const zone of profile.reservedZones || []) add(zone, "zone", "a reserved area", ["start", "end"])
return targets
}
function snapAxis(box, targets, threshold, axis, resize) {
if (!(threshold > 0)) return { delta: 0, guides: [] }
const moving = lines(box, axis), names = resize ? ["end"] : ["start", "center", "end"]
const matches = (name, target) => (name === "center") === (target.line === "center")
let best = null
for (const target of targets) {
if (target.axis !== axis) continue
for (const name of names) {
if (!matches(name, target)) continue
const delta = target.value - moving[name], distance = Math.abs(delta)
if (distance <= threshold && (!best || distance < best.distance ||
distance === best.distance && PRIORITY[target.kind] < PRIORITY[best.target.kind])) best = { delta, distance, target }
}
}
if (!best) return { delta: 0, guides: [] }
const guides = []
for (const target of targets) {
if (target.axis !== axis || !names.some(name => matches(name, target) && Math.abs(moving[name] + best.delta - target.value) <= EPSILON)) continue
const guide = { axis, value: target.value, kind: target.kind, label: target.label }
const index = guides.findIndex(item => Math.abs(item.value - guide.value) <= EPSILON)
if (index < 0) guides.push(guide)
else if (PRIORITY[guide.kind] < PRIORITY[guides[index].kind]) guides[index] = guide
}
return { delta: best.delta, guides }
}
export function snapMove(box, targets, threshold) {
const x = snapAxis(box, targets, threshold, "x", false), y = snapAxis(box, targets, threshold, "y", false)
return { x: box.x + x.delta, y: box.y + y.delta, guides: [...x.guides, ...y.guides] }
}
export function snapResize(box, targets, threshold) {
const x = snapAxis(box, targets, threshold, "x", true), y = snapAxis(box, targets, threshold, "y", true)
return { width: box.width + x.delta, height: box.height + y.delta, guides: [...x.guides, ...y.guides] }
}
export function snapThreshold(profile, rect, pointerType) {
return rect.width > 0 ? (pointerType === "touch" ? SNAP_DISTANCE.touch : SNAP_DISTANCE.fine) * profile.width / rect.width : 0
}
@@ -1,3 +1,5 @@
import { LEAD_INFO } from "./projection-layout.js"
export const PROFILES = ["large", "compact"]
const COLORS = ["cardFill", "cardBorder", "text"]
export const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
@@ -103,18 +105,23 @@ export function previewPoint(event, rect, profile) {
}
// Android Auto display switches, independent of the comma screen's preferences.
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback"]
// Text alignment inside the cruise and speed-limit card, matching onroad_customization.SPEED_ALIGNMENTS.
export const SPEED_ALIGNMENTS = ["left", "center", "right"]
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]
export function validDocument(document, metadata) {
if (!object(document)) return false
const profiles = metadata?.projection === true ? ["large"] : PROFILES
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : [])]) || document.version !== 6 ||
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 7 ||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
(Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") ||
(Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) ||
(Object.hasOwn(document, "clock24Hour") && typeof document.clock24Hour !== "boolean") ||
(Object.hasOwn(document, "showMaxSpeed") && typeof document.showMaxSpeed !== "boolean") ||
(Object.hasOwn(document, "display") && (!object(document.display) || !Object.values(document.display).every((value) => typeof value === "string"))) ||
(Object.hasOwn(document, "largeUiGammaTrial") && typeof document.largeUiGammaTrial !== "boolean") ||
PROJECTION_DISPLAY_SWITCHES.some(key => Object.hasOwn(document, key) && typeof document[key] !== "boolean") ||
(Object.hasOwn(document, "leadInfo") && !LEAD_INFO.includes(document.leadInfo)) ||
!keysEqual(document.palette, COLORS) || !COLORS.every((key) => HEX.test(document.palette[key])) ||
!keysEqual(document.layouts, profiles) || !keysEqual(document.widgetColors, profiles) || !keysEqual(document.roadColors, profiles)) return false
if (Object.hasOwn(document, "widgetOrder") && (!object(document.widgetOrder) ||
@@ -204,13 +211,13 @@ export function validSnapshot(data) {
![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits"].includes(widget.kind) ||
!object(widget.opacity) || ![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.backgroundToggle != null && (!projection || !["nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
widget.backgroundToggle !== true || !widget.opacity)) return false
if (widget.resizable && (widget.kind !== "steering_wheel" || !Number.isInteger(widget.resizable.min) ||
if (widget.resizable && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) ||
!Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) ||
widget.resizable.default !== widget.width || widget.width !== widget.height ||
widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height ||
+273 -126
View File
@@ -2,33 +2,47 @@ import { GxState } from "./state.js"
import { guardUnload } from "./unload-guard.js"
import { PollTimer, connectionError } from "./polling.js"
import { reactive, watch } from "../vendor/vue/vue.esm-browser.js"
import { LayoutPreviewFeed, PREVIEW_SCENES } from "./layout-preview.js"
import { editorSnapshot, projectionPayload } from "./projection-layout.js"
import { LayoutPreviewFeed, PREVIEW_SCENES, PreviewQueue, previewScene, sceneHidesWidget } from "./layout-preview.js"
import { LEAD_INFO, PROJECTION_WORDMARK, editorSnapshot, projectionPayload } from "./projection-layout.js"
import { GalaxySelect } from "./galaxy-select.js"
import { GxNotice } from "./notice.js"
import { GxDialog } from "./dialog.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
import { snapTargets, snapMove, snapResize, snapThreshold } from "./layout-snap.js"
import { PROFILES, ROAD_COLORS, PATH_MODES, HEX, PROJECTION_DISPLAY_SWITCHES, layoutDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js"
export { layoutDocument, withDisplay, orderedWidgetIds, widgetPalette, withAlpha, widgetSize, placementLimits, clampBox, clampPlacement, overlapsReserved, previewPoint, validDocument, validSnapshot } from "./onroad-layout-document.js"
const previewVisibilityKey = projection => "gx-layout-preview-visibility-v1:" + (projection ? "android-auto" : "comma")
const previewVisibilityKey = projection => "gx-layout-preview-visibility-v2:" + (projection ? "android-auto" : "comma")
function loadPreviewVisibility(projection) {
const hidden = { large: { stopped_timer: true }, compact: { stopped_timer: true } }
const hidden = { large: {}, compact: {} }
try {
const saved = JSON.parse(localStorage.getItem(previewVisibilityKey(projection)))
const raw = localStorage.getItem(previewVisibilityKey(projection))
const legacy = raw === null
const saved = JSON.parse(raw ?? localStorage.getItem(previewVisibilityKey(projection).replace('-v2:', '-v1:')))
for (const profile of PROFILES) {
const widgets = saved?.[profile]
if (!widgets || typeof widgets !== "object" || Array.isArray(widgets)) continue
hidden[profile] = {
...hidden[profile], ...Object.fromEntries(Object.entries(widgets)
.filter(([id, value]) => typeof value === "boolean" && !(projection && id === "car_exit")))
.filter(([id, value]) => typeof value === "boolean" && !(projection && id === "car_exit") && !(legacy && id === "stopped_timer")))
}
}
} catch { /* Preview controls still work when browser storage is unavailable. */ }
return hidden
}
const clone = (value) => JSON.parse(JSON.stringify(value))
const snapPreferenceKey = "gx-layout-snap-v1"
function loadSnapPreference() {
try { return localStorage.getItem(snapPreferenceKey) !== "0" } catch { return true }
}
const aidsPreferenceKey = "gx-layout-aids-v1"
function loadAidsPreference() {
try { return localStorage.getItem(aidsPreferenceKey) !== "0" } catch { return true }
}
// The backdrop hides every movable widget, so only the set of placed widgets, not their positions, changes it.
const backdropKey = (document) => ({ ...document, widgetOrder: undefined,
layouts: Object.fromEntries(Object.entries(document.layouts).map(([profile, widgets]) => [profile, Object.keys(widgets).sort()])) })
// Open maker pages; a route switch briefly mounts the next editor before the last one leaves.
let makerPagesOpen = 0
@@ -135,11 +149,11 @@ export const LayoutWidgetPreview = {
methods: {
withAlpha,
modeLines(scene, profile) {
const aol = scene === "aol"
const experimental = ["experimental", "cem_stop_light", "cem_lead", "cem_curve"].includes(scene)
const { mode, experimental } = previewScene(scene), aol = scene === "aol"
if (profile === "compact" && (aol || experimental)) return aol ? ["Always On", "Lateral"] : ["ACC -", "Experimental"]
return [aol ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill"]
return [mode]
},
pending(scene) { return scene === "general" || scene === "slc_pending" },
},
template: `
<g aria-hidden="true" class="gx-layout-widget-art" :fill="palette.text || '#ffffff'">
@@ -164,7 +178,7 @@ export const LayoutWidgetPreview = {
:y="widget.height / 2 + (index - (modeLines(scene, profile).length - 1) / 2) * 25">{{ line }}</tspan>
</text>
</template>
<template v-else-if="widget.kind === 'stopped_timer'">
<template v-else-if="widget.kind === 'stopped_timer' && ['general', 'stopped'].includes(scene)">
<text :x="widget.width / 2" :y="widget.height * 53 / 112" text-anchor="middle" dominant-baseline="middle" :font-size="widget.height * 64 / 112" font-weight="700" fill="#b67129"
style="filter: drop-shadow(2px 2px 1px #000000aa)">2 minutes</text>
<text :x="widget.width / 2" :y="widget.height * 101 / 112" text-anchor="middle" dominant-baseline="middle" :font-size="widget.height * 30 / 112" font-weight="500" fill="#fffffff2"
@@ -179,14 +193,18 @@ export const LayoutWidgetPreview = {
<rect x="10" y="8" width="100" height="116" rx="14" fill="none" stroke="white" stroke-width="2" />
<text x="60" y="18" dominant-baseline="text-before-edge" text-anchor="middle" font-size="20" font-weight="600" fill="white">SPEED</text>
<text x="60" y="36" dominant-baseline="text-before-edge" text-anchor="middle" font-size="20" font-weight="600" fill="white">LIMIT</text>
<text x="60" y="66" dominant-baseline="text-before-edge" text-anchor="middle" font-size="50" font-weight="700" fill="white">65</text>
<text x="60" y="66" dominant-baseline="text-before-edge" text-anchor="middle" font-size="50" font-weight="700" fill="white">45</text>
</template>
<template v-else-if="widget.kind === 'speed_limit_actions'">
<text v-if="profile === 'compact'" x="6" :y="widget.visualHeaderY ?? -32" dominant-baseline="text-before-edge" font-size="20" font-weight="600" fill="white">NEW LIMIT 55</text>
<text v-if="profile === 'large'" x="0" y="28" font-size="37" fill="white">New road limit 35</text>
<g v-for="(label, index) in (profile === 'large' ? ['Apply 39', 'Keep'] : ['ACCEPT', 'REJECT'])" :key="label" :transform="'translate(' + index * (widget.width / 2 + 4) + ' ' + (profile === 'large' ? 44 : 0) + ')'">
<text v-if="profile === 'compact' && pending(scene)" x="6" :y="widget.visualHeaderY ?? -32" dominant-baseline="text-before-edge" font-size="20" font-weight="600" fill="white">NEW LIMIT 49</text>
<text v-if="profile === 'large' && pending(scene)" x="0" y="28" font-size="37" fill="white">New road limit 49</text>
<template v-if="pending(scene)"><g v-for="(label, index) in (profile === 'large' ? ['Apply 54', 'Keep 49'] : ['ACCEPT', 'REJECT'])" :key="label" :transform="'translate(' + index * (widget.width / 2 + 4) + ' ' + (profile === 'large' ? 44 : 0) + ')'">
<rect :width="widget.width / 2 - 4" :height="profile === 'large' ? widget.height - 44 : widget.height" rx="12" :fill="palette.cardFill" :stroke="palette.cardBorder" stroke-width="2" />
<text :x="widget.width / 4 - 2" :y="(profile === 'large' ? widget.height - 44 : widget.height) / 2 + 7" text-anchor="middle" :font-size="profile === 'large' ? 40 : 20" :font-weight="profile === 'large' ? 500 : 600" :fill="palette.text">{{ label }}</text>
</g></template>
<g v-else-if="profile === 'large'">
<rect :width="widget.width" :height="widget.height - 44" rx="12" :fill="palette.cardFill" :stroke="palette.cardBorder" stroke-width="2" />
<text :x="widget.width / 2" :y="(widget.height - 44) / 2 + 7" text-anchor="middle" font-size="40" :fill="palette.text">Use limit 49</text>
</g>
</template>
<g v-else-if="widget.kind === 'torque_bar'" fill="none" stroke="white" stroke-linecap="round">
@@ -266,10 +284,10 @@ export const OnroadLayoutPage = {
setup(props) {
const state = reactive({
status: "idle", data: null, draft: null, needsReload: false, error: "", notice: "",
profile: "large", scene: "engaged", selected: null, layerDrag: null, drag: null, discard: null, leavePresentation: null, removeConfirm: null, colorError: "", placementError: "",
inspectorPanel: ["widgets", "edit", "road", "display"].includes(props.initialPanel) && !props.projection ? props.initialPanel : "widgets", previewMode: "layout", showFavoriteZones: false, devicePreviewOpen: false,
aboutOpen: false, tipsOpen: false, moreOpen: false, widgetsHelpOpen: false, stuck: { leaveBar: false, pinned: false, sections: false }, openColor: null, art: { status: "idle", error: "", url: null, sprites: null }, preview: { status: "idle", error: "", url: null },
hiddenPreviewWidgets: loadPreviewVisibility(props.projection), history: { undo: [], redo: [], group: null }
profile: "large", scene: "general", selected: null, layerDrag: null, drag: null, discard: null, leavePresentation: null, removeConfirm: null, colorError: "", placementError: "",
inspectorPanel: ["widgets", "edit", "road", "display"].includes(props.initialPanel) && !props.projection ? props.initialPanel : "widgets", showFavoriteZones: false, showEditingAids: loadAidsPreference(),
aboutOpen: false, tipsOpen: false, moreOpen: false, widgetsHelpOpen: false, stuck: { leaveBar: false, pinned: false, sections: false }, openColor: null, art: { status: "idle", error: "", url: null, sprites: null }, backdrop: { status: "idle", error: "", url: null },
hiddenPreviewWidgets: loadPreviewVisibility(props.projection), sceneHiddenPreviewWidgets: {}, snapEnabled: loadSnapPreference(), snapGuides: [], history: { undo: [], redo: [], group: null }
})
let deferred = null
const apply = (update) => {
@@ -313,37 +331,36 @@ export const OnroadLayoutPage = {
}
const feed = new OnroadLayoutFeed({ projection: props.projection, unauthorized: props.unauthorized, publish: apply })
watch(() => !!state.drag || !!state.layerDrag, (dragging) => { if (!dragging && deferred) apply({}) }, { flush: "sync" })
const previewFeed = new LayoutPreviewFeed({
unauthorized: props.unauthorized,
publish: (update) => Object.assign(state.preview, update)
})
const artFeed = new LayoutPreviewFeed({ art: true, unauthorized: props.unauthorized,
const queue = new PreviewQueue()
const backdropFeed = new LayoutPreviewFeed({ backdrop: true, unauthorized: props.unauthorized, queue,
publish: update => Object.assign(state.backdrop, update) })
const artFeed = new LayoutPreviewFeed({ art: true, unauthorized: props.unauthorized, queue,
publish: update => Object.assign(state.art, update) })
watch(() => [state.draft && JSON.stringify(state.draft), state.profile, state.scene, !!state.drag,
!!state.layerDrag, state.data?.revision, state.previewMode, state.data?.editable], () => {
if (props.mode !== "local" || state.previewMode !== "layout" || !state.data?.editable || !state.draft) {
if (artFeed.active) artFeed.stop()
!!state.layerDrag, state.data?.revision, state.data?.editable], () => {
if (props.mode !== "local" || !state.data?.editable || !state.draft) {
artFeed.stop()
return
}
if (!artFeed.active) artFeed.start()
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : layoutDocument(state.draft)
artFeed.update(document, props.projection ? "projection" : state.profile, state.scene, !!state.drag || !!state.layerDrag)
}, {flush: "sync"})
watch(() => [state.draft && JSON.stringify(state.draft), state.profile, state.scene, !!state.drag,
state.data?.revision, state.previewMode], () => {
if (props.mode !== "local" || state.previewMode !== "device" || !state.draft) {
if (previewFeed.active) previewFeed.stop()
// The real screen behind the arrangement: status border, road, alerts and sidebar, without the movable widgets.
watch(() => [state.draft && JSON.stringify(backdropKey(state.draft)), state.profile, state.scene,
state.data?.revision, state.data?.editable], () => {
if (props.mode !== "local" || !state.data?.editable || !state.draft) {
backdropFeed.stop()
return
}
state.devicePreviewOpen = true
if (!previewFeed.active) previewFeed.start()
if (!backdropFeed.active) backdropFeed.start()
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : layoutDocument(state.draft)
previewFeed.update(document, props.projection ? 'projection' : state.profile, state.scene, !!state.drag)
backdropFeed.update(document, props.projection ? "projection" : state.profile, state.scene, false, backdropKey(state.draft))
}, {flush: "sync"})
watch(() => props.mode, (mode) => {
if (mode !== "local") { state.devicePreviewOpen = false; previewFeed.stop(); artFeed.stop() }
if (mode !== "local") { backdropFeed.stop(); artFeed.stop() }
})
return { state, feed, previewFeed, artFeed, scenes: PREVIEW_SCENES }
return { state, feed, backdropFeed, artFeed, scenes: PREVIEW_SCENES, projectionWordmark: PROJECTION_WORDMARK }
},
computed: {
busy() { return ["loading", "saving"].includes(this.state.status) },
@@ -358,7 +375,25 @@ export const OnroadLayoutPage = {
modalLeavePrompt() { return this.pendingLeave && this.state.leavePresentation === "modal" },
saveAvailable() { return !!this.state.data?.editable && !this.state.error && !this.state.needsReload && this.dirty && !this.state.drag && !this.state.layerDrag },
canSavePending() { return this.saveAvailable && !this.busy },
profile() { return this.state.data?.metadata.profiles[this.state.profile] },
profile() {
const profile = this.state.data?.metadata.profiles[this.state.profile], cruise = profile?.widgets?.cruise_limits
// Speed-limit sources draw inside the cruise card or as a small card under it, so its box grows to match.
if (!cruise?.sourcesExtent || this.state.draft?.speedSources !== true) return profile
const grow = this.state.draft.speedSourcesCard === true ? cruise.sourcesExtent.card : cruise.sourcesExtent.inline
return { ...profile, widgets: { ...profile.widgets, cruise_limits: { ...cruise, height: cruise.height + grow } } }
},
// Only the scenes that look different on this screen.
availableScenes() {
const screen = this.projection ? "projection" : this.state.profile
return this.scenes.filter(scene => !scene.hiddenOn?.includes(screen))
},
sceneGroups() {
return [["Editing", scene => scene.id === "general"], ["Driving", scene => scene.id !== "general" && !/^(cem|slc)_/.test(scene.id)], ["Conditional experimental", scene => scene.id.startsWith("cem_")],
["Speed limit", scene => scene.id.startsWith("slc_")]].map(([label, test]) => ({ label, scenes: this.availableScenes.filter(test) }))
},
sceneBorder() { return previewScene(this.state.scene).border },
// The last screen for this profile stays up while the next scene renders, instead of flashing the sketch.
nativeBackdropReady() { return !!this.state.backdrop.url && this.state.backdrop.profile === (this.projection ? "projection" : this.state.profile) },
layout() { return this.state.draft?.layouts[this.state.profile] },
projectionHasPipWidget() {
return ["pip_left", "pip_right"].some(id => this.state.draft?.layouts?.large?.[id]?.enabled === true)
@@ -399,6 +434,18 @@ export const OnroadLayoutPage = {
selectedWidget() { return this.profile?.widgets[this.state.selected] },
selectedPosition() { return this.layout?.[this.state.selected] },
selectedSize() { return this.selectedWidget ? widgetSize(this.selectedWidget, this.selectedPosition) : [0, 0] },
// A finger covers the preview chip, so touch drags show the alignment in the sticky save bar.
snapInBar() { return !!(this.state.drag?.touch && this.state.snapGuides.length) },
snapSummary() {
const guides = this.state.snapGuides, centered = new Set(guides.filter(guide => guide.kind === "center").map(guide => guide.axis))
if (centered.size === 2) return "Centered on the screen"
// A centered axis is described once; the other axis still says what it lines up with.
const labels = [...new Set(guides.filter(guide => !centered.has(guide.axis)).map(guide => guide.label))]
const others = labels.filter(label => label !== "the edge").slice(0, 2)
const text = [centered.has("x") ? "centered across" : centered.has("y") ? "centered top to bottom" : "",
labels.includes("the edge") ? "at the edge" : "", others.length ? "lined up with " + others.join(" and ") : ""].filter(Boolean).join(", ")
return text && text[0].toUpperCase() + text.slice(1)
},
selectedColors() { return this.selectedWidget ? this.widgetColors(this.state.selected) : {} },
colorFields() {
return (this.state.data?.metadata.paletteFields || []).filter((field) =>
@@ -439,19 +486,42 @@ export const OnroadLayoutPage = {
this.observePinned()
},
updated() { this.observePinned() },
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.previewFeed.stop(); this.artFeed.stop(); this._stopUnloadGuard(); this._pinObserver?.disconnect(); this.unwatchRelease(); this._closeFloating?.()
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.backdropFeed.stop(); this.artFeed.stop(); this._stopUnloadGuard(); this._pinObserver?.disconnect(); this.unwatchRelease(); this._closeFloating?.()
if (--makerPagesOpen === 0) document.documentElement.classList.remove("gx-maker-open")
if (this._onPinScroll) window.removeEventListener("scroll", this._onPinScroll, { capture: true })
},
methods: {
switchRow,
runtimeWidget(id) { return this.profile?.runtimePreviewWidgets?.includes(id) === true },
previewHidden(id) { return !(this.projection && id === "car_exit") && this.state.hiddenPreviewWidgets[this.state.profile]?.[id] === true },
toggleSnap() {
if (this.state.drag || this.state.layerDrag) return
this.state.snapEnabled = !this.state.snapEnabled
try { localStorage.setItem(snapPreferenceKey, this.state.snapEnabled ? "1" : "0") } catch { }
},
toggleEditingAids() {
this.state.showEditingAids = !this.state.showEditingAids
try { localStorage.setItem(aidsPreferenceKey, this.state.showEditingAids ? "1" : "0") } catch { }
},
sceneHidden(id) { return sceneHidesWidget(this.state.scene, this.state.profile, id, this.layout, this.profile?.widgets) },
previewHidden(id) {
if (this.projection && id === "car_exit") return false
if (this.sceneHidden(id)) return true
const hidden = this.state.scene === "general" ? this.state.hiddenPreviewWidgets : this.state.sceneHiddenPreviewWidgets[this.state.scene]
return hidden?.[this.state.profile]?.[id] === true
},
visibilityTitle(id, label) {
return this.sceneHidden(id) ? label + ' is hidden in this scene. Choose General edit to arrange it.' :
(this.previewHidden(id) ? 'Show ' : 'Hide ') + label + ' in this preview only'
},
togglePreviewVisibility(id) {
if (this.state.drag || this.state.layerDrag || (this.projection && id === "car_exit")) return
const hidden = this.state.hiddenPreviewWidgets[this.state.profile] ||= {}
if (this.state.drag || this.state.layerDrag || (this.projection && id === "car_exit") || this.sceneHidden(id)) return
const preferences = this.state.scene === "general" ? this.state.hiddenPreviewWidgets :
(this.state.sceneHiddenPreviewWidgets[this.state.scene] ||= {})
const hidden = preferences[this.state.profile] ||= {}
hidden[id] = !hidden[id]
try { localStorage.setItem(previewVisibilityKey(this.projection), JSON.stringify(this.state.hiddenPreviewWidgets)) } catch { }
if (this.state.scene === "general") {
try { localStorage.setItem(previewVisibilityKey(this.projection), JSON.stringify(this.state.hiddenPreviewWidgets)) } catch { }
}
},
selectWidget(id) {
this.state.selected = id
@@ -516,11 +586,6 @@ export const OnroadLayoutPage = {
}
}
},
selectPreview(mode) {
if (this.state.drag) return
this.state.previewMode = mode
if (mode === "device") this.showDevicePreview()
},
reorderLayer(id, target) {
if (!this.editable || this.state.drag || id === target ||
this.profile.widgets[id]?.frontmost || this.profile.widgets[target]?.frontmost) return
@@ -680,29 +745,14 @@ export const OnroadLayoutPage = {
this.state.colorError = this.state.placementError = ""
this.state.notice = "Stock StarPilot colors and both layouts are restored in your draft. Save to apply them."
},
showDevicePreview() {
if (this.mode !== "local" || !this.state.data || !this.state.draft || this.state.drag || this.state.devicePreviewOpen) return
this.state.devicePreviewOpen = true
this.state.previewMode = "device"
if (this.artFeed.active) this.artFeed.stop()
// The synchronous mode watcher starts the selected feed; avoid starting it twice.
if (!this.previewFeed.active) {
this.previewFeed.start()
this.previewFeed.update(this.projection ? projectionPayload({ document: this.state.draft }, this.state.data.metadata).document : layoutDocument(this.state.draft), this.projection ? 'projection' : this.state.profile, this.state.scene)
}
},
hideDevicePreview() {
this.state.previewMode = "layout"
this.state.devicePreviewOpen = false
if (this.previewFeed.active) this.previewFeed.stop()
Object.assign(this.state.preview, { status: "idle", error: "", url: null })
},
selectProfile(profile) {
if (!this.availableProfiles.includes(profile) || this.state.drag) return
this.state.snapGuides = []
this.finishColorEdit()
this.state.profile = profile
this.state.selected = Object.keys(this.profile.widgets)[0] || null
this.state.placementError = ""
if (!this.availableScenes.some(scene => scene.id === this.state.scene)) this.state.scene = "general"
},
changePosition(id, x, y) {
if (!this.editable) return
@@ -719,6 +769,7 @@ export const OnroadLayoutPage = {
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
const before = clone(this.state.draft)
this.layout[id].enabled = true
if (this.sceneHidden(id)) this.state.scene = "general"
this.recordChange(before)
this.state.selected = id
this.state.notice = ""
@@ -774,6 +825,29 @@ export const OnroadLayoutPage = {
this.recordChange(before)
this.state.notice = ""
},
setSpeedSources(key, value) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
!["speedSources", "speedSourcesCard"].includes(key)) return
const before = clone(this.state.draft), oldHeight = this.profile.widgets.cruise_limits.height
this.state.draft[key] = value === true
const card = this.layout.cruise_limits, actions = this.layout.speed_limit_actions, height = this.profile.widgets.cruise_limits.height
// Actions that sat right under the card follow its new bottom; both stay on screen.
if (actions && actions.x === card.x && actions.y === card.y + oldHeight + 22) actions.y = card.y + height + 22
for (const id of ["cruise_limits", "speed_limit_actions"].filter(id => this.layout[id])) {
const limits = placementLimits(this.profile, id, this.layout)
this.layout[id].y = Math.max(limits.minY, Math.min(this.layout[id].y, limits.maxY))
}
this.recordChange(before)
this.state.notice = ""
},
setSpeedAlign(align) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
!["left", "center", "right"].includes(align)) return
const before = clone(this.state.draft)
this.state.draft.speedAlign = align
this.recordChange(before)
this.state.notice = ""
},
setLargeUiGammaTrial(value) {
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag) return
const before = clone(this.state.draft)
@@ -789,6 +863,19 @@ export const OnroadLayoutPage = {
this.recordChange(before)
this.state.notice = "Save to apply the screen options on Android Auto."
},
setProjectionLeadInfo(value) {
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag ||
this.state.draft.leadIndicator !== true || !LEAD_INFO.includes(value)) return
const before = clone(this.state.draft)
this.state.draft.leadInfo = value
this.recordChange(before)
this.state.notice = "Save to apply the screen options on Android Auto."
},
projectionLeadInfoRow() {
const marker = this.state.draft.leadIndicator === true
return { label: "Lead Info", value: LEAD_INFO.includes(this.state.draft.leadInfo) ? this.state.draft.leadInfo : "Off",
choices: LEAD_INFO, available: marker, reason: marker ? "" : "Turn on Lead Indicator first", action: true }
},
setFavoriteDisplay(display) {
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.iconSize ||
!["words", "icons"].includes(display)) return
@@ -843,6 +930,7 @@ export const OnroadLayoutPage = {
},
startDrag(id, event, fromTray = false) {
if (!this.editable || event.button > 0 || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
if (fromTray && this.sceneHidden(id)) this.state.scene = "general"
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
if (!point) return
const position = this.layout[id], widget = this.profile.widgets[id]
@@ -850,7 +938,7 @@ export const OnroadLayoutPage = {
this.state.selected = id
this.finishColorEdit()
this.state.drag = {
id, pointerId: event.pointerId, fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray,
id, pointerId: event.pointerId, touch: event.pointerType === "touch", fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray,
startX: event.clientX, startY: event.clientY, moved: false,
offsetX: fromTray ? width / 2 : point.x - position.x,
offsetY: fromTray ? height / 2 : point.y - position.y
@@ -869,7 +957,7 @@ export const OnroadLayoutPage = {
this.state.selected = id
this.finishColorEdit()
this.state.drag = {
id, pointerId: event.pointerId, resize: true, before: { ...position }, historyBefore: clone(this.state.draft),
id, pointerId: event.pointerId, touch: event.pointerType === "touch", resize: true, before: { ...position }, historyBefore: clone(this.state.draft),
inside: true, offsetX: point.x - position.width, offsetY: point.y - position.height
}
this._dragTarget = this.$refs.preview
@@ -923,28 +1011,56 @@ export const OnroadLayoutPage = {
this.selectedPosition.opacity = value
this.recordChange(before, this.state.profile + ":" + this.state.selected + ":opacity")
},
setWidgetBackground(event) {
setWidgetBackground(value) {
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.backgroundToggle) return
this.finishColorEdit()
const before = clone(this.state.draft)
this.selectedPosition.background = event.target.checked
this.selectedPosition.background = value === 'On'
this.recordChange(before)
this.state.notice = ""
},
moveDrag(event) {
const drag = this.state.drag
if (!drag || drag.pointerId !== event.pointerId) return
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
const rect = this.$refs.preview.getBoundingClientRect(), point = previewPoint(event, rect, this.profile)
this.state.snapGuides = []
if (!point) return
if (drag.resize) { this.resizeBox(drag.id, point.x - drag.offsetX, point.y - drag.offsetY); return }
const snapping = this.state.snapEnabled && !event.altKey
if (drag.resize) {
const raw = { width: point.x - drag.offsetX, height: point.y - drag.offsetY }, position = this.layout[drag.id]
if (snapping) {
drag.targets ||= snapTargets(this.profile, this.layout, drag.id, id => !this.previewHidden(id))
const snapped = snapResize({ x: position.x, y: position.y, ...raw }, drag.targets, snapThreshold(this.profile, rect, event.pointerType))
if (this.resizeBox(drag.id, snapped.width, snapped.height)) {
if (position.width === snapped.width && position.height === snapped.height) this.state.snapGuides = snapped.guides
return
}
}
this.resizeBox(drag.id, raw.width, raw.height)
return
}
// A finger wobbles a few pixels on every tap; only a deliberate move drags the widget.
if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < DRAG_SLOP) return
drag.moved = true
drag.inside = point.x >= 0 && point.y >= 0 && point.x <= this.profile.width && point.y <= this.profile.height
if (!drag.fromTray || drag.inside) {
const x = point.x - drag.offsetX, y = point.y - drag.offsetY
if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, x, y, this.layout) !== null
this.changePosition(drag.id, x, y)
const raw = { x: point.x - drag.offsetX, y: point.y - drag.offsetY }
let next = raw
if (snapping) {
const [width, height] = widgetSize(this.profile.widgets[drag.id], this.layout[drag.id])
drag.targets ||= snapTargets(this.profile, this.layout, drag.id, id => !this.previewHidden(id))
// Pushing past an edge parks the widget on it, so snap from where the clamp will leave it.
const { minX, maxX, minY, maxY } = placementLimits(this.profile, drag.id, this.layout)
const inside = { x: Math.max(minX, Math.min(maxX, raw.x)), y: Math.max(minY, Math.min(maxY, raw.y)) }
const snapped = snapMove({ ...inside, width, height }, drag.targets, snapThreshold(this.profile, rect, event.pointerType))
const clamped = clampPlacement(this.profile, drag.id, snapped.x, snapped.y, this.layout)
if (clamped) {
next = snapped
if (clamped.x === Math.round(snapped.x) && clamped.y === Math.round(snapped.y)) this.state.snapGuides = snapped.guides
}
}
if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, next.x, next.y, this.layout) !== null
this.changePosition(drag.id, next.x, next.y)
}
if (drag.fromTray) this.layout[drag.id].enabled = drag.inside
},
@@ -960,6 +1076,7 @@ export const OnroadLayoutPage = {
},
releaseDrag() {
const id = this.state.drag?.pointerId
this.state.snapGuides = []
this.state.drag = null
if (this._dragTarget?.hasPointerCapture?.(id)) this._dragTarget.releasePointerCapture(id)
this._dragTarget = null
@@ -1084,9 +1201,9 @@ export const OnroadLayoutPage = {
leave(action) {
this.state.discard = null
this.state.leavePresentation = null
if (typeof action === "function") { this.hideDevicePreview(); action() }
else if (action === "back") { this.hideDevicePreview(); this.$emit("close") }
else if (["projection", "device"].includes(action)) { this.hideDevicePreview(); this.$emit("target", action) }
if (typeof action === "function") action()
else if (action === "back") this.$emit("close")
else if (["projection", "device"].includes(action)) this.$emit("target", action)
else this.feed.load()
},
save() { if (this.editable && this.dirty && !this.state.drag && !this.state.layerDrag) return this.feed.save(this.state.draft) },
@@ -1122,6 +1239,7 @@ export const OnroadLayoutPage = {
<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>
@@ -1148,25 +1266,29 @@ export const OnroadLayoutPage = {
<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 : profile.width + " × " + profile.height }}</span></div>
<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">
<nav class="gx-layout__switcher gx-settings-tabs" aria-label="Preview type">
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.previewMode === 'layout'" :disabled="!!state.drag" @click="selectPreview('layout')"><span>Arrange<span class="gx-layout__long"> widgets</span></span></button>
<button class="gx-btn gx-btn--tonal" :aria-pressed="state.previewMode === 'device'" :disabled="!!state.drag" @click="selectPreview('device')"><span>Device<span class="gx-layout__long"> preview</span></span></button>
</nav>
<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>
<div v-if="state.previewMode === 'layout'" class="gx-layout__scenes" aria-label="Arrangement sample scene">
<button v-for="scene in scenes" :key="scene.id" class="gx-btn gx-btn--tonal" type="button" :aria-pressed="state.scene === scene.id"
:disabled="!!state.drag || !!state.layerDrag" @click="state.scene = scene.id">{{ scene.label }}</button>
</div>
<p v-if="state.previewMode === 'layout' && state.art.status === 'unavailable'" class="gx-note" role="status">Widget preview unavailable. Placement controls remain available.</p>
<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="!projection && state.showFavoriteZones && state.previewMode === 'layout'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
<svg v-show="state.previewMode === 'layout'" ref="preview" class="gx-layout__preview" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
<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">
@@ -1186,7 +1308,6 @@ export const OnroadLayoutPage = {
opacity=".7" :stroke-width="state.profile === 'compact' ? 2 : 5" />
<rect v-if="!projection" :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'" aria-hidden="true" pointer-events="none">
<text x="1874" y="17" font-size="14" font-weight="600" fill="#ffffffd2">SAMPLE DATA</text>
<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" />
@@ -1194,14 +1315,19 @@ export const OnroadLayoutPage = {
<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>
<rect :x="profile.bounds.x" :y="profile.bounds.y" :width="profile.bounds.width" :height="profile.bounds.height" fill="none" stroke="#8495a855" stroke-width="2" stroke-dasharray="8 8" />
<g v-if="!projection && state.showFavoriteZones" aria-hidden="true" pointer-events="none">
</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 : profile.bounds.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="profile.bounds.x" :y="profile.bounds.y" :width="profile.bounds.width" :height="profile.bounds.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}"
<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(' + layout[widget.id].x + ' ' + layout[widget.id].y + ')'" tabindex="0" role="button"
:aria-label="widget.label + ', x ' + layout[widget.id].x + ', y ' + layout[widget.id].y +
(widget.frontmost ? '. Arrow keys move; Shift moves ten pixels; required and always frontmost widget.' :
@@ -1217,27 +1343,29 @@ export const OnroadLayoutPage = {
<path d="M-6 6 L6 -6 M0 6 L6 0" stroke="#16121f" stroke-width="3" stroke-linecap="round" />
</g>
</g>
<rect v-if="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" />
<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 : 0" :x2="guide.axis === 'x' ? guide.value : 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>
<section v-if="state.devicePreviewOpen" class="gx-layout__device" v-show="state.previewMode === 'device'" aria-label="Device preview">
<div class="gx-layout__subhead"><strong>{{ projection ? 'Android Auto renderer preview' : 'Device preview' }}</strong><span>Preview scene</span></div>
<div class="gx-layout__scenes" aria-label="Device preview scene">
<button v-for="scene in scenes" :key="scene.id" class="gx-btn gx-btn--tonal" type="button" :aria-pressed="state.scene === scene.id"
:disabled="!!state.drag" @click="state.scene = scene.id">{{ scene.label }}</button>
</div>
<div class="gx-layout__device-frame" :style="{ aspectRatio: profile.width + ' / ' + profile.height }">
<button class="gx-icon-btn gx-layout__device-stop" type="button" :disabled="!!state.drag" aria-label="Stop Device preview" title="Stop Device preview" @click="hideDevicePreview"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<svg class="gx-layout__device-outline" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" aria-hidden="true"><rect v-if="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>
<img v-if="state.preview.url" :src="state.preview.url" alt="Device-rendered sample driving screen" />
<span v-else role="status">{{ state.preview.status === 'editing' ? 'Finish moving the widget to refresh Device preview.' : state.preview.status === 'updating' ? 'Updating Device preview…' : state.preview.status === 'unavailable' ? state.preview.error : 'Device preview is waiting.' }}</span>
</div>
</section>
<!-- 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 in Arrange widgets to place them. Readings are samples.</p>
<p class="gx-note">Add Stopped timer to preview its minutes and seconds. Device preview renders the same widget as the driving screen; choose Stopped timer for a sample at rest.</p>
<p class="gx-note">The steering wheel 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 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>
@@ -1254,7 +1382,7 @@ export const OnroadLayoutPage = {
<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 eye hides a widget in this preview only. The driving screen doesn't change.</p>
<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"
@@ -1268,9 +1396,9 @@ export const OnroadLayoutPage = {
@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"
:aria-label="(previewHidden(widget.id) ? 'Show ' : 'Hide ') + widget.label + ' in arrangement preview'"
:title="(previewHidden(widget.id) ? 'Show ' : 'Hide ') + widget.label + ' in arrangement preview only'"
<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>
@@ -1287,7 +1415,7 @@ export const OnroadLayoutPage = {
<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 || state.previewMode !== 'layout'" :aria-label="'Drag ' + widget.label + ' onto preview'"
<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>
@@ -1301,6 +1429,7 @@ export const OnroadLayoutPage = {
<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"
@@ -1308,20 +1437,33 @@ export const OnroadLayoutPage = {
<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' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
<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>
<label v-if="selectedWidget.backgroundToggle"><input type="checkbox" :checked="selectedPosition.background"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setWidgetBackground"> Show background</label>
<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>
<label v-if="projection && selectedWidget.kind === 'steering_wheel'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.wheelPedalFeedback === true"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setProjectionDisplay('wheelPedalFeedback', $event.target.checked ? 'On' : 'Off')"> Brake and acceleration colors</label>
<label v-if="!projection && selectedWidget.kind === 'steering_wheel' && displayRow('ShowBrakeStatus')" class="gx-layout__clock-format"><input type="checkbox" :checked="displayValue('ShowBrakeStatus') === 'On'"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('ShowBrakeStatus'))" @change="setDisplay('ShowBrakeStatus', $event.target.checked ? 'On' : 'Off')"> Brake and acceleration colors</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">
@@ -1416,17 +1558,22 @@ export const OnroadLayoutPage = {
<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"
<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"
<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"
<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 Trial', state.draft.largeUiGammaTrial)" :index="1"
<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">Gamma trial: the large UI road-camera color effect on Android Auto (not shown in the preview).</p>
<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>
@@ -1,14 +1,33 @@
// Translate the independent AA placement document into the existing editor model.
const clone = (value) => JSON.parse(JSON.stringify(value))
// Text above the Android Auto lead marker, matching projection_layout.LEAD_INFO_CHOICES.
export const LEAD_INFO = ["Off", "Distance", "Speed"]
// projection_onroad draws the fixed brand at (width - 210, height - 90),
// using Sora-800.fnt at 30 * Profile.LARGE.font_scale. Reuse that atlas and
// its glyph metrics so the browser needs no substitute font or native request.
const brandGlyphs = {
S: [331, 6, 131, 155, 43], t: [6, 175, 87, 142, 54], a: [474, 6, 120, 117, 80],
r: [913, 6, 85, 112, 82], P: [184, 6, 135, 148, 46], i: [606, 6, 69, 157, 38],
l: [687, 6, 65, 147, 47], o: [764, 6, 137, 119, 79],
}
export const PROJECTION_WORDMARK = {
right: 210, bottom: 90, scale: 30 * 1.242 / 200,
glyphs: [..."StarPilot"].map((letter, index, letters) => {
const [x, y, width, height, offsetY] = brandGlyphs[letter]
const offsetX = letters.slice(0, index).reduce((sum, char) => sum + brandGlyphs[char][2], 0)
return { x, y, width, height, offsetX, offsetY }
}),
}
export function editorSnapshot(raw) {
if (!raw || raw.version !== 1 || !raw.screen || !raw.metadata || !raw.document || !raw.defaults)
throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.")
const colors = raw.colors
const document = (value) => {
if (value.version !== 2 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
if (value.version !== 3 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.")
return { version: 6, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
return { version: 7, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, speedSourcesCard: value.speedSourcesCard === true, speedAlign: ["center", "right"].includes(value.speedAlign) ? value.speedAlign : "left", ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
}
return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults),
@@ -19,7 +38,7 @@ export function editorSnapshot(raw) {
}
export function projectionPayload(body, metadata) {
return { revision: body.revision, document: { version: 2,
return { revision: body.revision, document: { version: 3,
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
clock24Hour: body.document.clock24Hour === true,
largeUiGammaTrial: body.document.largeUiGammaTrial === true,
@@ -28,6 +47,11 @@ export function projectionPayload(body, metadata) {
blindSpotCamera: body.document.blindSpotCamera === true,
hidePersonalityAlerts: body.document.hidePersonalityAlerts === true,
wheelPedalFeedback: body.document.wheelPedalFeedback === true,
leadIndicator: body.document.leadIndicator === true,
leadInfo: LEAD_INFO.includes(body.document.leadInfo) ? body.document.leadInfo : "Off",
speedSources: body.document.speedSources === true,
speedSourcesCard: body.document.speedSourcesCard === true,
speedAlign: ["center", "right"].includes(body.document.speedAlign) ? body.document.speedAlign : "left",
...(body.document.widgetOrder?.large ? { widgetOrder: clone(body.document.widgetOrder.large) } : {}),
widgets: clone(body.document.layouts.large) } }
}
+1 -1
View File
@@ -13,7 +13,7 @@ const integer = value => Number.isSafeInteger(value) && value >= 0
const token = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), value => value.toString(16).padStart(2, "0")).join("")
const mediaSources = () => [globalThis.MediaSource, globalThis.ManagedMediaSource].filter(Source => typeof Source === "function")
const STREAMING_UNAVAILABLE = "Replay video streaming is unavailable in this browser. Update your browser or try another browser."
const PLAY_BUFFER = .5, MAX_BUFFER_AHEAD = 1, MEDIA_INTERVAL = 200
const PLAY_BUFFER = .5, MAX_BUFFER_AHEAD = 1, MEDIA_INTERVAL = 50
export const replayableSegment = segment => !!((segment.files.rlog || segment.files.qlog) && (segment.files.fcamera || segment.files.qcamera))
export function validReplayStatus(value) {
const ranges = value?.seekRanges
+2 -1
View File
@@ -43,5 +43,6 @@ export async function loadCatalog(options) {
(tool.visibility !== undefined && tool.visibility !== "authenticated"))) {
throw new Error("Invalid Galaxy catalog")
}
return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor }
return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor,
instance: typeof runtime.instance === "string" ? runtime.instance : null }
}
@@ -25,7 +25,7 @@ export class UpdateRestart {
start(baseline) {
if (this.baseline) {
this.baseline = { ...this.baseline, ...baseline }
this.baseline = { ...this.baseline, ...baseline, instance: this.baseline.instance ?? baseline.instance }
try { this.storage.setItem(UPDATE_RESTART_KEY, JSON.stringify(this.baseline)) } catch {}
return
}
@@ -44,7 +44,9 @@ export class UpdateRestart {
if (data?.schemaVersion !== 1 || !installed || !data.operations) throw new Error("Status unavailable")
const changed = installed.commit && (installed.commit !== this.baseline.commit || installed.branch !== this.baseline.branch)
const finished = request?.state === "failed" || request?.outcome === "up_to_date"
const returned = !request && data.updater?.state === "idle" && changed
const restarted = typeof this.baseline.instance === "string" && typeof data.instance === "string" &&
data.instance !== this.baseline.instance
const returned = !request && data.updater?.state === "idle" && (changed || restarted)
if (finished || returned) {
this.baseline = null
if (this.retry !== null) this.cancel(this.retry)
@@ -27,7 +27,9 @@ export async function widgetArtMetadata(blob) {
if (!/^[a-z_]+$/.test(id) || !rect || ![rect.x,rect.y,rect.width,rect.height].every(Number.isInteger) ||
rect.x < 0 || rect.y < 0 || rect.width < 1 || rect.height < 1 ||
rect.x + rect.width > metadata.width || rect.y + rect.height > metadata.height ||
![rect.offsetX ?? 0, rect.offsetY ?? 0].every(value => Number.isFinite(value) && value > -1 && value <= 0)) throw new Error("Invalid widget art rectangle")
!Number.isFinite(rect.offsetX ?? 0) || (rect.offsetX ?? 0) <= -1 || (rect.offsetX ?? 0) > 0 ||
!Number.isFinite(rect.offsetY ?? 0) || (rect.offsetY ?? 0) <= (id === 'speed_limit_actions' ? -33 : -1) ||
(rect.offsetY ?? 0) > 0) throw new Error("Invalid widget art rectangle")
}
return metadata
}
@@ -0,0 +1,92 @@
Copyright 2019 The Sora Project Authors (https://github.com/sora-xor/sora-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1 -1
View File
@@ -25,7 +25,7 @@ def atomic_json(path: Path, value):
temporary = path.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(value, stream, allow_nan=False, separators=(',', ':'))
stream.write(json.dumps(value, allow_nan=False, separators=(',', ':')))
temporary.replace(path)
+32 -20
View File
@@ -280,6 +280,8 @@ class Compositor:
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
self.camera_class = AugmentedRoadView
self.camera = self.camera_class()
# Recorded onroad transitions must not reconnect the replay camera.
ui_state._offroad_transition_callbacks.remove(self.camera._offroad_transition)
self.resources.callback(self._close_camera)
self.adapter = RuntimeSnapshotAdapter(ui_state)
from openpilot.starpilot.ui.pip_preferences import read_pip
@@ -328,17 +330,16 @@ class Compositor:
self.view.compact_sidebar.personality_renderer = self._personality_layer
def _camera_layer(self, rect, state):
self.primary_ready = self._primary_matches()
if self.profile == 'compact':
if state.appearance.camera_view == 4:
self.rl.draw_rectangle_rec(rect, self.rl.BLACK)
self._receive_primary()
return
if ((state.appearance.camera_view == 1 or state.reverse_driver_camera) and not self.cabin or
state.appearance.camera_view == 3 and not self.wide):
self.rl.draw_rectangle_rec(rect, self.rl.BLACK)
from openpilot.system.ui.widgets.label import gui_label
gui_label(rect, 'recorded camera unavailable', font_size=28)
self._receive_primary()
return
sm = self.ui.sm
receipts = {name: sm.recv_frame[name] for name in
@@ -360,9 +361,10 @@ class Compositor:
reverse_driver=state.reverse_driver_camera,
lead_indicator=state.appearance.show_lead_indicator and state.lead_indicator_source_fresh,
lead_info_mode=state.appearance.lead_info_mode, lead_info_metric=state.appearance.lead_info_metric,
lateral_active=state.lateral_active, road_style=state.customization['roadColors']['compact'])
lateral_active=state.lateral_active, road_style=state.customization['roadColors']['compact'],
poll_camera=False)
else:
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'])
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'], poll_camera=False)
finally:
sm.updated = original
self.render_receipts.update({name: receipts[name] for name in flags.read if name in receipts})
@@ -395,10 +397,7 @@ class Compositor:
self.camera.client, self.camera.frame, state, sm.logMonoTime[name], sample.boot_ns))
def _receive_primary(self):
if self.camera._ensure_connection():
frame = self.camera.client.recv(timeout_ms=0)
if frame is not None:
self.camera.frame = frame
self.camera._poll_camera()
self.primary_ready = self._primary_matches()
def _pip_layer(self, rect, state, *, submit=None):
@@ -464,13 +463,12 @@ class Compositor:
self.gui._fallback_fonts.clear()
def update(self):
# Select the camera before HUD messages and keep that selection through drawing.
self._receive_primary()
self.ui.update()
sample = self.ui.replay_sample
if sample is not None and sample.valid and sample.epoch != self.epoch:
self.reset_epoch(sample.epoch)
# Poll before re-priming: msgq's first receive synchronizes a subscriber
# created before its publisher and can discard that publisher's first frame.
self._receive_primary()
# Connect the optional PiP client before the one-shot paused primer.
if self.cabin and self.pip_saved.enabled is True:
self.pip_stream.set_active(True)
@@ -486,8 +484,8 @@ class Compositor:
frame = self.camera.frame if self._primary_matches() else None
self._close_camera()
self.camera = self.camera_class(stream_type=stream)
# Preserve the already-subscribed client: replacing it after the paused
# primer would require another seek and create an endless epoch loop.
self.ui._offroad_transition_callbacks.remove(self.camera._offroad_transition)
# Preserve the subscriber to avoid repeated seeks while paused.
self.camera.client = client
self.camera.frame = frame
self.camera.available_streams = available
@@ -507,22 +505,36 @@ class Compositor:
self.render_receipts = {}
self.last_route_frame = None
def draw(self, epoch: int, position: float, now_ns: int):
def _draw_rejection(self, epoch: int):
sample = self.ui.replay_sample
if sample is None or sample.now_ns is None:
if sample is None or sample.now_ns is None or not sample.valid:
return 'invalid_clock'
if sample.epoch != epoch:
return 'wrong_epoch'
if (epoch, sample.now_ns) == self.last_route_frame:
return 'unchanged_clock'
# OnroadView skips the camera layer when unavailable, so this draw cannot publish.
if not (self._primary_matches() or self._optional_placeholder_ready()):
return 'camera_not_ready'
return None
def _prepare(self):
for prepare in tuple(self.gui._render_prepare):
prepare()
def draw(self, epoch: int, position: float, now_ns: int):
if self._draw_rejection(epoch) is not None:
return False
sample = self.ui.replay_sample
key = epoch, sample.now_ns
if key == self.last_route_frame:
return False
from openpilot.starpilot.system.android_auto import gpu_nv12
from openpilot.starpilot.ui.shell import ShellMode
rl = self.rl
self.context.begin_frame(sample.now_ns / 1e9)
self.context.begin_frame(cast(int, sample.now_ns) / 1e9)
self.placement.maintain(now_ns / 1e9)
snapshot = self.adapter.build(ShellMode.ONROAD)
self.primary_ready = False
for prepare in tuple(self.gui._render_prepare):
prepare()
self._prepare()
rl.begin_texture_mode(self.content)
try:
rl.clear_background(rl.BLACK)
+270 -6
View File
@@ -1,5 +1,5 @@
from contextlib import nullcontext
from dataclasses import dataclass
from dataclasses import dataclass, replace
from types import SimpleNamespace
from typing import Any
from unittest.mock import Mock
@@ -9,6 +9,7 @@ import pytest
from openpilot.starpilot.replay import render
from openpilot.starpilot.replay.render import Compositor, Engine, camera_options, frame_matches, parse_wall_offset
from openpilot.starpilot.ui.presentation import Profile
from openpilot.tools.replay.display_clock import ClockSample
def test_camera_selection_uses_selected_sources_and_current_preferences():
@@ -52,7 +53,7 @@ def test_camera_matches_recorded_identity_across_distinct_clock_domains():
def test_paused_same_route_clock_never_advances_filters_or_gpu():
core: Any = object.__new__(Compositor)
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(now_ns=100))
core.ui = SimpleNamespace(replay_sample=ClockSample(100, 100, 100, epoch=2, paused=True, valid=True))
core.last_route_frame = (2, 100)
core.context = Mock()
assert core.draw(2, 0, 9_000_000_000) is False
@@ -76,7 +77,7 @@ def test_recorded_camera_preview_does_not_require_initial_device_state(frame_rea
viewport_width: int = 0
core: Any = object.__new__(Compositor)
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(now_ns=100),
core.ui = SimpleNamespace(replay_sample=ClockSample(100, 100, 100, epoch=2, valid=True),
sm=SimpleNamespace(seen={'extrinsicsCalibration': False}))
core.last_route_frame = None
core.context = core.placement = Mock()
@@ -95,12 +96,261 @@ def test_recorded_camera_preview_does_not_require_initial_device_state(frame_rea
core.converter = Mock()
core.readback = Mock()
assert core.draw(2, 0, 9_000_000_000) is frame_ready
if not frame_ready:
core.context.begin_frame.assert_not_called()
core.adapter.build.assert_not_called()
core.view.render.assert_not_called()
core.readback.start.assert_not_called()
return
drawn = core.view.render.call_args.args[0]
assert drawn.camera_available is frame_ready
assert core.adapter.build.return_value.onroad.camera_available is False
assert core.readback.start.call_count == int(frame_ready)
@pytest.fixture(params=(Profile.LARGE, Profile.COMPACT))
def draw_core(request):
from openpilot.cereal.visionipc import VisionStreamType
if request.param == Profile.COMPACT:
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
else:
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation
class Messages(dict):
pass
idx = SimpleNamespace(frameId=5, timestampSof=9_000_000_000, timestampEof=9_010_000_000)
sm = Messages(narrowRoadEncodeIdx=idx, wideRoadEncodeIdx=idx)
sm.seen = dict.fromkeys(('narrowRoadEncodeIdx', 'wideRoadEncodeIdx', 'modelV2', 'radarState', 'carParams',
'carState', 'extrinsicsCalibration'), True)
sm.valid = dict(sm.seen)
sm.logMonoTime = dict.fromkeys(sm, 9_020_000_000)
sm.logMonoTime['extrinsicsCalibration'] = 1_000_000_000
sm.recv_frame = dict.fromkeys(sm.seen, 2)
sm.updated = {'modelV2': True}
client = Mock(frame_id=5, timestamp_sof=idx.timestampSof, timestamp_eof=idx.timestampEof)
frame = SimpleNamespace(frame_id=5)
client.recv.return_value = frame
core: Any = object.__new__(Compositor)
core.profile = request.param
core.wide = core.cabin = False
core.ui = SimpleNamespace(sm=sm, replay_sample=ClockSample(1_000_000_000, 9_050_000_000, 100, epoch=2, valid=True))
core.camera = Mock(client=client, frame=frame, stream_type=VisionStreamType.VISION_STREAM_NARROW_ROAD)
core.camera._switching = False
core.camera._ensure_connection.return_value = True
core.camera._poll_camera.side_effect = lambda: CameraView._poll_camera(core.camera)
core.last_route_frame = None
core.render_receipts = {'modelV2': 1}
core.context, core.placement, core.adapter = Mock(), Mock(), Mock()
core.adapter.build.return_value = SimpleNamespace(onroad=OnroadState(True, False, 15., 80., SpeedLimitObservation()))
core.rl, core.content = Mock(), Mock()
core.gui = SimpleNamespace(_render_prepare=[Mock()], _frame=0)
core.scale = 1
core.view, core.converter, core.readback = Mock(), Mock(), Mock()
core.output = None
def paint(*args, **kwargs):
assert kwargs['poll_camera'] is False
core.camera.render_camera_model_layer.side_effect = paint
def render_view(state):
# The real OnroadView enters its camera layer only with this availability flag.
if state.camera_available:
core._camera_layer(Mock(), state)
core.view.render.side_effect = render_view
return core
@pytest.mark.parametrize('change', ('ahead', 'behind', 'missing_frame', 'timestamp', 'stale_state', 'unseen', 'invalid'))
def test_known_camera_rejection_skips_preparation_and_preserves_updates(draw_core, change):
core = draw_core
if change in ('ahead', 'behind'):
core.camera.client.frame_id = 6 if change == 'ahead' else 4
elif change == 'missing_frame':
core.camera.frame = None
elif change == 'timestamp':
core.camera.client.timestamp_sof += 1
elif change == 'stale_state':
core.ui.sm.logMonoTime['narrowRoadEncodeIdx'] = 8_000_000_000
else:
getattr(core.ui.sm, 'seen' if change == 'unseen' else 'valid')['narrowRoadEncodeIdx'] = False
assert core.draw(2, 0., 100) is False
core.context.begin_frame.assert_not_called()
core.adapter.build.assert_not_called()
core.gui._render_prepare[0].assert_not_called()
core.view.render.assert_not_called()
core.readback.start.assert_not_called()
assert core.last_route_frame is None and core.gui._frame == 0
assert core.render_receipts == {'modelV2': 1} and core.ui.sm.updated['modelV2']
@pytest.mark.parametrize('sample', (ClockSample(100, 100, 100, epoch=2),
ClockSample(100, 100, 100, epoch=3, valid=True)))
def test_invalid_or_other_epoch_clock_never_advances_draw(draw_core, sample):
core = draw_core
core.ui.replay_sample = sample
assert core.draw(2, 0., 100) is False
core.context.begin_frame.assert_not_called()
core.adapter.build.assert_not_called()
def test_camera_poll_precedes_message_snapshot():
core: Any = object.__new__(Compositor)
order = []
core.ui = SimpleNamespace(update=lambda: order.append('messages'), replay_sample=ClockSample(1, 1, 1, epoch=2, valid=True))
core.epoch, core.cabin = 2, False
core._receive_primary = lambda: order.append('camera')
assert core.update() is core.ui.replay_sample
assert order == ['camera', 'messages']
@pytest.mark.parametrize('profile', ('onroad', 'mici.onroad'))
def test_camera_paint_does_not_receive_or_switch_selected_frame(monkeypatch, profile):
import importlib
import numpy as np
import pyray as rl
from openpilot.cereal.visionipc import VisionStreamType
module = importlib.import_module(f'openpilot.selfdrive.ui.{profile}.cameraview')
monkeypatch.setattr(module, 'COMMA_HARDWARE', True)
camera = object.__new__(module.CameraView)
camera.close = Mock()
camera.client = Mock()
camera.frame = SimpleNamespace(frame_id=5, width=100, height=50)
frame = camera.frame
camera._stream_type = VisionStreamType.VISION_STREAM_NARROW_ROAD
camera._switching = True
camera._handle_switch = Mock()
camera._ensure_connection = Mock()
camera._calc_frame_matrix = Mock(return_value=np.eye(3))
camera._render_egl = Mock()
camera._render(rl.Rectangle(0, 0, 100, 50), poll_camera=False)
assert camera.frame is frame
camera.client.recv.assert_not_called()
camera._handle_switch.assert_not_called()
camera._ensure_connection.assert_not_called()
camera._render_egl.assert_called_once()
def test_compact_driver_switch_waits_for_camera_poll(monkeypatch):
from openpilot.selfdrive.ui.mici.onroad import augmented_road_view as module
camera = object.__new__(module.AugmentedRoadView)
camera.close = Mock()
camera._stream_type = module.NARROW_ROAD_CAM
camera._target_stream_type = module.DRIVER_CAM
camera._switching = True
camera.available_streams = [module.DRIVER_CAM]
camera._ensure_connection = Mock()
camera._handle_switch = Mock()
camera._switch_stream_if_needed = Mock(return_value=module.DRIVER_CAM)
monkeypatch.setattr(module.ui_state, 'started', True)
monkeypatch.setattr(module.rl, 'draw_rectangle_rec', Mock())
monkeypatch.setattr(module, 'gui_label', Mock())
camera.render_camera_model_layer(Mock(), camera_view=module.CAMERA_VIEW_DRIVER, poll_camera=False)
camera._handle_switch.assert_not_called()
camera._ensure_connection.assert_not_called()
@pytest.mark.parametrize('replacement_id', (5, 6))
def test_selected_camera_is_painted_before_next_poll(draw_core, replacement_id):
core = draw_core
def receive(**kwargs):
core.camera.client.frame_id = replacement_id
return SimpleNamespace(frame_id=replacement_id)
core.camera.client.recv.side_effect = receive
assert core.draw(2, 0., 100)
core.camera.client.recv.assert_not_called()
core._receive_primary()
core.ui.replay_sample = replace(core.ui.replay_sample, now_ns=1_050_000_000)
assert core.draw(2, .05, 150) is (replacement_id == 5)
core.camera.client.recv.assert_called_once_with(timeout_ms=0)
assert core.readback.start.call_count == 1 + int(replacement_id == 5)
def test_optional_placeholder_still_allows_later_camera_recovery(draw_core, monkeypatch):
from openpilot.cereal.visionipc import VisionStreamType
core = draw_core
monkeypatch.setattr('openpilot.system.ui.widgets.label.gui_label', Mock())
core.camera.stream_type = VisionStreamType.VISION_STREAM_WIDE_ROAD
core.camera.client.frame_id = 4
def receive(**kwargs):
core.camera.client.frame_id = 5
return SimpleNamespace(frame_id=5)
core.camera.client.recv.side_effect = receive
assert core.draw(2, 0., 100)
core.camera.client.recv.assert_not_called()
core._receive_primary()
core.ui.replay_sample = replace(core.ui.replay_sample, now_ns=1_050_000_000)
assert core.draw(2, .05, 150)
core.camera.client.recv.assert_called_once_with(timeout_ms=0)
assert core.readback.start.call_count == 2
def test_selected_buffer_reuse_still_fails_final_identity_check(draw_core):
core = draw_core
core.camera.render_camera_model_layer.side_effect = lambda *args, **kwargs: setattr(core.camera.frame, 'frame_id', 6)
assert not core.draw(2, 0., 100)
core.readback.start.assert_not_called()
def test_eligible_retained_camera_draws_fresh_hud_and_route_time(draw_core):
core = draw_core
frame = core.camera.frame
core.camera.client.recv.return_value = None
assert core.draw(2, 0., 100)
core.ui.replay_sample = replace(core.ui.replay_sample, now_ns=1_050_000_000)
core.adapter.build.return_value.onroad = replace(core.adapter.build.return_value.onroad, speed_mps=20.)
assert core.draw(2, .05, 150)
core.ui.replay_sample = replace(core.ui.replay_sample, now_ns=1_100_000_000)
assert core.draw(2, .1, 200) # Animation advances even without another source update.
assert core.camera.frame is frame
assert core.view.render.call_args.args[0].speed_mps == 20.
assert [call.args[0] for call in core.context.begin_frame.call_args_list] == [1., 1.05, 1.1]
assert core.gui._frame == 3
def test_rejected_camera_can_match_next_update_without_losing_model_receipt(draw_core):
core = draw_core
core.camera.client.frame_id = 6
core.camera.frame = SimpleNamespace(frame_id=6)
assert not core.draw(2, 0., 100)
core.ui.sm['narrowRoadEncodeIdx'].frameId = 6
def consume_model(*args, **kwargs):
assert core.ui.sm.updated['modelV2']
assert kwargs['poll_camera'] is False
core.camera.client.recv.return_value = None
core.camera.render_camera_model_layer.side_effect = consume_model
assert core.draw(2, 0., 100)
assert core.render_receipts['modelV2'] == core.ui.sm.recv_frame['modelV2']
assert core.context.begin_frame.call_count == 1
def test_optional_missing_camera_still_publishes_existing_placeholder(draw_core, monkeypatch):
from openpilot.cereal.visionipc import VisionStreamType
core = draw_core
label = Mock()
monkeypatch.setattr('openpilot.system.ui.widgets.label.gui_label', label)
core.camera.stream_type = VisionStreamType.VISION_STREAM_WIDE_ROAD
core.camera.client.frame_id = 4
core.camera.client.recv.return_value = None
assert core.draw(2, 0., 100)
label.assert_called_once()
core.readback.start.assert_called_once()
@pytest.mark.parametrize('camera_view', (1, 3, 4))
@pytest.mark.parametrize('draw_core', (Profile.COMPACT,), indirect=True)
def test_compact_hidden_and_unrecorded_camera_keep_selected_frame(draw_core, camera_view, monkeypatch):
core = draw_core
core.adapter.build.return_value.onroad = replace(core.adapter.build.return_value.onroad, appearance=replace(
core.adapter.build.return_value.onroad.appearance, camera_view=camera_view))
monkeypatch.setattr('openpilot.system.ui.widgets.label.gui_label', Mock())
core._receive_primary()
assert core.draw(2, 0., 100)
core.camera.client.recv.assert_called_once_with(timeout_ms=0)
core.camera.render_camera_model_layer.assert_not_called()
@pytest.mark.parametrize('profile', ('onroad', 'mici.onroad'))
@pytest.mark.parametrize('replay', (False, True))
def test_camera_layers_allow_recorded_preview_and_preserve_live_start_gate(monkeypatch, profile, replay):
@@ -246,7 +496,15 @@ def test_epoch_reset_preserves_native_camera_stream_and_eligible_paused_frame(mo
client.is_connected.return_value = True
frame = SimpleNamespace(frame_id=5)
core.camera = Mock(client=client, frame=frame, stream_type=0, available_streams=streams)
core.camera_class = Mock(return_value=Mock())
callbacks = []
replacement = Mock()
def transition():
replacement.client, replacement.frame = Mock(), None
replacement._offroad_transition = transition
def camera_class(**kwargs):
callbacks.append(transition)
return replacement
core.camera_class = camera_class
core._close_camera = Mock()
core._new_view = Mock()
core.readback = Mock(pending=False)
@@ -257,9 +515,12 @@ def test_epoch_reset_preserves_native_camera_stream_and_eligible_paused_frame(mo
sm.__getitem__ = Mock(return_value=idx)
sm.seen = sm.valid = {'narrowRoadEncodeIdx': True}
sm.logMonoTime = {'narrowRoadEncodeIdx': 9_020_000_000}
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(valid=True, boot_ns=9_050_000_000), sm=sm)
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(valid=True, boot_ns=9_050_000_000), sm=sm,
_offroad_transition_callbacks=callbacks)
core.render_receipts = {'modelV2': 1}
core.reset_epoch(3)
for callback in callbacks:
callback()
assert core.render_receipts == {}
assert core.camera.client is client
assert core.camera.available_streams == streams
@@ -305,6 +566,7 @@ def test_initial_hud_primer_required_even_when_camera_received_a_frame(tmp_path,
def test_early_camera_subscriber_is_polled_before_paused_reprime():
from openpilot.cereal.visionipc import VisionStreamType
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
sample = SimpleNamespace(valid=True, epoch=1, boot_ns=9_050_000_000)
idx = SimpleNamespace(frameId=5, timestampSof=9_000_000_000, timestampEof=9_010_000_000)
sm = Mock()
@@ -315,11 +577,13 @@ def test_early_camera_subscriber_is_polled_before_paused_reprime():
client = Mock(frame_id=5, timestamp_sof=idx.timestampSof, timestamp_eof=idx.timestampEof)
# The first receive synchronizes with a publisher created after this client.
client.recv.side_effect = [None, frame]
core = object.__new__(Compositor)
core: Any = object.__new__(Compositor)
core.ui = Mock(replay_sample=sample, sm=sm)
core.epoch, core.cabin = 1, False
core.camera = Mock(client=client, frame=None, stream_type=VisionStreamType.VISION_STREAM_NARROW_ROAD)
core.camera._switching = False
core.camera._ensure_connection.return_value = True
core.camera._poll_camera.side_effect = lambda: CameraView._poll_camera(core.camera)
core.update()
assert not core._primary_matches()
core.update()
@@ -304,6 +304,7 @@ def identity_status(directory: Path | None = None) -> dict:
"certificate_sha256": provenance.get("certificate_sha256", ""),
"imported": provenance.get("imported", ""),
"source": provenance.get("source", ""),
"app_version": provenance.get("app_version", ""),
}
@@ -357,16 +358,16 @@ class ImportJob:
self.work_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
return self.work_dir / "android-auto-upload.bin"
def start(self, *, path: Path | None = None, url: str = "", enabled=None) -> None:
def start(self, *, path: Path | None = None, url: str = "", enabled=None, app_version: str | None = None) -> None:
with self.lock: # check and claim together, so two requests cannot both start
if self.busy():
raise IdentityImportError("An import is already running")
self.state = {"state": "running", "stage": "downloading" if url else "reading", "started": datetime.now(UTC).timestamp(),
"downloaded": 0, "total": 0}
self.thread = threading.Thread(target=self._run, args=(path, url, enabled), name="android_auto_identity_import", daemon=True)
self.thread = threading.Thread(target=self._run, args=(path, url, enabled, app_version), name="android_auto_identity_import", daemon=True)
self.thread.start()
def _run(self, path: Path | None, url: str, enabled=None) -> None:
def _run(self, path: Path | None, url: str, enabled=None, app_version: str | None = None) -> None:
def progress(**values):
if enabled is not None and not enabled():
raise IdentityImportError("Import cancelled: Android Auto is disabled", "CANCELLED")
@@ -378,6 +379,8 @@ class ImportJob:
if url:
download(url, source, lambda done, total: progress(downloaded=done, total=total))
files, metadata = extract_identity(source, root_sha256=self.root_sha256, progress=lambda stage: progress(stage=stage))
if app_version and "app_version" not in metadata:
metadata["app_version"] = app_version
progress(stage="installing")
install_identity(files, metadata, self.identity_dir)
self._set(state="done", stage="done", finished=datetime.now(UTC).timestamp(), expires=metadata["expires"],
@@ -24,13 +24,15 @@ VERSION = re.compile(r"[0-9]+(?:\.[0-9]+)+-release", re.ASCII)
MAX_BODY_BYTES = 2 * 1024 * 1024
MAX_DEPTH = 8
SUCCESS_TTL = 6 * 3600
FAILURE_TTL = 15 * 60
FAILURE_TTL = 60 # a failed lookup (e.g. at boot before the clock syncs) retries soon
RETRY_DELAYS = (3, 8) # three tries before the page falls back to APKMirror
LINK_MARGIN = 3600 # never hand out a link that expires within the hour
_lock = threading.Lock()
_cache: dict | None = None
_refresh_after = 0.0
_in_flight = False
_sleep = time.sleep
def _now() -> float:
@@ -156,7 +158,10 @@ def start_background_refresh(now=time.monotonic) -> None:
global _cache, _in_flight, _refresh_after
result = None
try:
result = fetch_latest()
for delay in (*RETRY_DELAYS, None):
if (result := fetch_latest()) is not None or delay is None:
break
_sleep(delay)
except Exception:
pass # The upload path must keep working when the hint is unavailable.
finally:
@@ -173,6 +178,13 @@ def start_background_refresh(now=time.monotonic) -> None:
_refresh_after = time.monotonic() + FAILURE_TTL
def lookup_pending() -> bool:
"""True while a lookup is still trying and there's no usable link to show yet."""
with _lock:
in_flight = _in_flight
return in_flight and download_status() is None
def download_status(now: float | None = None) -> dict | None:
"""The cached link for the browser, without any network work."""
with _lock:
@@ -180,3 +192,10 @@ def download_status(now: float | None = None) -> dict | None:
if release is None or release["expires"] - LINK_MARGIN <= (_now() if now is None else now):
return None
return {"version": release["version"], "url": release["url"], "size": release["size"]}
def version_for_size(size: int) -> str | None:
"""The cached release's version when a package has exactly its size; a display label only."""
with _lock:
release = _cache
return release["version"] if release is not None and release["size"] == size else None
@@ -189,9 +189,9 @@ def run(frames_path: str, touch_path: str | None = None, control_path: str | Non
gui_app._scale = 1.0
gui_app._render_texture = None
from openpilot.starpilot.system.android_auto.identity import certificate_days_left
from openpilot.starpilot.system.android_auto.projection_layout_runtime import ProjectionLayoutWatcher
from openpilot.starpilot.system.android_auto.projection_layout_runtime import ProjectionLayoutWatcher, comma_display_seed
viewport = (geometry.logical_width, geometry.logical_height)
watcher = ProjectionLayoutWatcher(viewport)
watcher = ProjectionLayoutWatcher(viewport, display_seed=comma_display_seed)
layout = ProjectionOnroad(viewport=viewport, customization=watcher.load(),
certificate_days=certificate_days_left(), layout_watcher=watcher)
resources.callback(layout.close)
@@ -6,8 +6,8 @@ from pathlib import Path
from openpilot.starpilot.system.android_auto.identity import DATA_DIR
from openpilot.starpilot.system.android_auto.display_profile import screen_geometry
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET, WHEEL_SIZES,
customization_metadata, validate_widget_order)
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET,
CEM_GAUGE_FRAME, CEM_GAUGE_INSET, SPEED_ALIGNMENTS, customization_metadata, validate_widget_order)
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
@@ -111,23 +111,36 @@ def layout_metadata_for_viewport(viewport):
'note': "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.",
'default': {'x': 30, 'y': height - 30 - exit_h, 'enabled': True}}
for key in ACTION_WIDGETS:
widgets[key].update(opacity={'min': CAR_EXIT_MIN_OPACITY if key == CAR_EXIT else 0, 'max': 100, 'default': 100}, backgroundToggle=True)
widgets[key]['default'].update(opacity=100, background=True)
opacity = 75 if key == CAR_EXIT else 100
widgets[key].update(opacity={'min': CAR_EXIT_MIN_OPACITY if key == CAR_EXIT else 0, 'max': 100, 'default': opacity}, backgroundToggle=True)
widgets[key]['default'].update(opacity=opacity, background=key != CAR_EXIT)
# Background opacity for the cruise and speed-limit card; its text stays solid.
widgets['cruise_limits'].update(opacity={'min': 0, 'max': 100, 'default': 100})
widgets['cruise_limits']['default'].update(opacity=100)
profile["widgetOrder"] = [NAV_MAP, *profile["widgetOrder"], STOPPED_TIMER_WIDGET, NAV_CARD, *DISPLAY_WIDGETS, CAR_EXIT]
return profile
# Android Auto display switches, independent of the comma screen's preferences.
DISPLAY_DEFAULTS = {'blindSpotBorder': False, 'signalBorder': False, 'blindSpotCamera': False,
'hidePersonalityAlerts': False, 'wheelPedalFeedback': False}
'hidePersonalityAlerts': False, 'wheelPedalFeedback': False,
'leadIndicator': False, 'leadInfo': 'Off', 'speedSources': False,
'speedSourcesCard': False, 'speedAlign': 'left'}
# Lead info shown above the lead marker, in LeadInfoMode order.
LEAD_INFO_CHOICES = ('Off', 'Distance', 'Speed')
def projection_appearance(appearance, document):
"""The comma's onroad appearance with every car-screen choice taken from the AA layout instead."""
from dataclasses import replace
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, LeadInfoMode
lead_indicator = document.get('leadIndicator', False) is True
lead_info = document.get('leadInfo', 'Off')
# Like the comma, lead info only shows with the lead marker on.
lead_info_mode = LeadInfoMode(LEAD_INFO_CHOICES.index(lead_info)) if lead_indicator and lead_info in LEAD_INFO_CHOICES else LeadInfoMode.OFF
# The car screen always shows the road camera; the comma's camera choices stay on the comma.
return replace(appearance,
show_lead_indicator=lead_indicator, lead_info_mode=lead_info_mode,
show_blindspot_border=document.get('blindSpotBorder', False) is True,
show_signal_border=document.get('signalBorder', False) is True,
hide_personality_alerts=document.get('hidePersonalityAlerts', False) is True,
@@ -135,14 +148,26 @@ def projection_appearance(appearance, document):
camera_view=CameraViewChoice.STANDARD, driver_camera_on_reverse=False)
def default_layout(screen):
def comma_display(appearance):
"""The comma screen's display choices as AA switches, to start a car screen that has no saved layout."""
lead_indicator = appearance.show_lead_indicator is True
return {'blindSpotBorder': appearance.show_blindspot_border is True,
'signalBorder': appearance.show_signal_border is True,
'hidePersonalityAlerts': appearance.hide_personality_alerts is True,
'wheelPedalFeedback': appearance.wheel_pedal_feedback is True,
'leadIndicator': lead_indicator,
'leadInfo': LEAD_INFO_CHOICES[appearance.lead_info_mode] if lead_indicator else 'Off'}
def default_layout(screen, display=None):
geometry = screen_geometry(screen)
return default_layout_for_viewport((geometry.logical_width, geometry.logical_height))
return default_layout_for_viewport((geometry.logical_width, geometry.logical_height), display)
def default_layout_for_viewport(viewport):
def default_layout_for_viewport(viewport, display=None):
metadata = layout_metadata_for_viewport(viewport)
return {'version': 2, 'clock24Hour': False, 'largeUiGammaTrial': False, **DISPLAY_DEFAULTS, 'canvas': {key: metadata[key] for key in ('width', 'height')},
display = {**DISPLAY_DEFAULTS, **{key: value for key, value in (display or {}).items() if key in DISPLAY_DEFAULTS}}
return {'version': 3, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
'widgets': {key: {**widget['default'], **({'size': 192} if key == 'steering_wheel' else {})}
for key, widget in metadata['widgets'].items()}}
@@ -150,8 +175,8 @@ def default_layout_for_viewport(viewport):
def placement_size(key, widget, placement):
if key in DISPLAY_WIDGETS and placement.get('display') == 'icons':
return FAVORITE_ICON_SIZE, FAVORITE_ICON_SIZE
if key == 'steering_wheel':
return placement.get('size', 192), placement.get('size', 192)
if widget.get('resizable'):
return (placement.get('size', widget['resizable']['default']),) * 2
if key == NAV_MAP:
return placement['width'], placement['height']
return widget['width'], widget['height']
@@ -179,7 +204,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if type(value) is dict:
fields |= {field for field in ('widgetOrder', 'clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS) if field in value}
if (type(value) is not dict or set(value) != fields or
type(value['version']) is not int or value['version'] not in (1, 2) or
type(value['version']) is not int or value['version'] not in (1, 2, 3) or
value['canvas'] != {key: metadata[key] for key in ('width', 'height')} or
type(value['widgets']) is not dict or not set(value['widgets']) <= set(metadata['widgets']) or
not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS}):
@@ -192,7 +217,9 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
raise ValueError('Invalid large UI gamma trial')
result.setdefault('largeUiGammaTrial', False)
for field, default in DISPLAY_DEFAULTS.items():
if field in value and type(value[field]) is not bool:
if field in value and (type(value[field]) is not type(default) or
(field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES) or
(field == 'speedAlign' and value[field] not in SPEED_ALIGNMENTS)):
raise ValueError('Invalid projection display setting')
result.setdefault(field, default)
for key in set(metadata['widgets']) - set(value['widgets']):
@@ -213,9 +240,17 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if key in ACTION_WIDGETS and type(placement) is dict:
placement.setdefault('opacity', 100)
placement.setdefault('background', True)
fields = ({'x', 'y', 'enabled'} | ({'size'} if key == 'steering_wheel' else set()) |
if key == 'cruise_limits' and type(placement) is dict:
placement.setdefault('opacity', 100)
if key == 'driver_monitor' and type(placement) is dict and 'size' not in placement:
for axis in ('x', 'y'):
if type(placement.get(axis)) in (int, float):
placement[axis] += 32
placement['size'] = widget['resizable']['default']
fields = ({'x', 'y', 'enabled'} | ({'size'} if widget.get('resizable') else set()) |
({'display'} if key in DISPLAY_WIDGETS else set()) |
({'opacity', 'background'} if key in ACTION_WIDGETS else set()) |
({'opacity'} if key == 'cruise_limits' else set()) |
({'width', 'height', 'opacity'} if key == NAV_MAP else set()))
if type(placement) is not dict or set(placement) != fields or type(placement['enabled']) is not bool:
raise ValueError('Invalid projection widget')
@@ -226,11 +261,14 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if key in ACTION_WIDGETS and (type(placement['opacity']) is not int or not 0 <= placement['opacity'] <= 100 or
type(placement['background']) is not bool):
raise ValueError('Invalid action widget appearance')
if key == 'cruise_limits' and (type(placement['opacity']) is not int or not 0 <= placement['opacity'] <= 100):
raise ValueError('Invalid cruise card opacity')
if key == CAR_EXIT and placement['opacity'] < CAR_EXIT_MIN_OPACITY:
raise ValueError('Exit to car opacity must be at least 25%')
size = placement.get('size', 192)
if key == 'steering_wheel' and (type(size) is not int or not WHEEL_SIZES['large'][0] <= size <= WHEEL_SIZES['large'][2]):
raise ValueError('Invalid steering wheel size')
if widget.get('resizable'):
size, limits = placement['size'], widget['resizable']
if type(size) is not int or not limits['min'] <= size <= limits['max']:
raise ValueError('Invalid widget size')
if key == NAV_MAP:
box, opacity = widget['box'], widget['opacity']
if (type(placement['width']) is not int or not box['minWidth'] <= placement['width'] <= box['maxWidth'] or
@@ -238,6 +276,10 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
type(placement['opacity']) is not int or not opacity['min'] <= placement['opacity'] <= opacity['max']):
raise ValueError('Invalid map overlay size or opacity')
dimensions = placement_size(key, widget, placement)
# Version 2 and older saved the CEM gauge's full drawing frame; check that, then move to the inset box.
old_gauge = key == CEM_GAUGE_WIDGET and value['version'] < 3 and key in value['widgets']
if old_gauge:
dimensions = CEM_GAUGE_FRAME
if _legacy_geometry and key in ('cruise_limits', 'speed_limit_actions'):
historic = {'cruise_limits': ((176, 483), (280, 412)), 'speed_limit_actions': ((176, 58), (280, 96))}
# Both shapes were published as v1; validate the whole footprint before migrating.
@@ -250,6 +292,10 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if (type(number) not in (int, float) or not math.isfinite(number) or
not bounds[axis] <= number <= bounds[axis] + bounds[extent] - dimension):
raise ValueError('Projection widget outside screen')
if old_gauge and not _legacy_geometry:
placement.update(x=placement['x'] + CEM_GAUGE_INSET[0], y=placement['y'] + CEM_GAUGE_INSET[1])
if not _legacy_geometry:
result['version'] = 3
if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES:
raise ValueError('Projection layout too large')
return result
@@ -305,4 +351,14 @@ def projection_customization(document, base_customization):
for key, placement in document['widgets'].items():
dx, dy = shifts.get(key, (0, 0))
result['layouts']['large'][key] = {**placement, 'x': placement['x'] - dx, 'y': placement['y'] - dy}
from openpilot.starpilot.ui.onroad_customization import rgba
# Card opacity is a color, not a placement: fold it into the fill and keep the native placement shape.
opacity = result['layouts']['large'].get('cruise_limits', {}).pop('opacity', 100)
if opacity != 100:
red, green, blue, alpha = rgba(result, 'cardFill', 'large', 'cruise_limits')
fill = f'#{red:02X}{green:02X}{blue:02X}{round(alpha * opacity / 100):02X}'
result['widgetColors']['large'].setdefault('cruise_limits', {})['cardFill'] = fill
for key in ('speedSources', 'speedSourcesCard', 'speedAlign'):
if key in document:
result[key] = document[key]
return result
@@ -12,10 +12,28 @@ from openpilot.starpilot.system.android_auto.projection_layout import (
)
def load_projection_layout(viewport, source=None):
def comma_display_seed():
"""Display choices for a car screen with no saved layout yet: the comma screen's own."""
from openpilot.common.params import Params
from openpilot.starpilot.system.android_auto.projection_layout import comma_display
from openpilot.starpilot.ui.appearance_preferences import onroad_appearance
return comma_display(onroad_appearance(Params()))
def _unsaved_layout(viewport, display_seed):
try:
display = display_seed() if display_seed is not None else None
except Exception:
display = None # an unreadable comma preference must never block the car screen
return default_layout_for_viewport(viewport, display)
def load_projection_layout(viewport, source=None, display_seed=None):
raw, readable = read_saved(source or ProjectionLayoutSource(), DOCUMENT_KEY, MAX_BYTES)
if not readable or raw is None:
if not readable:
return default_layout_for_viewport(viewport)
if raw is None:
return _unsaved_layout(viewport, display_seed)
try:
return decode_layout_for_viewport(raw, viewport)
except (ValueError, TypeError, UnicodeError, OverflowError, RecursionError):
@@ -28,9 +46,10 @@ CHECK_SECONDS = 1.0
class ProjectionLayoutWatcher:
"""Loads the layout and, on its own thread, picks up each layout Galaxy saves."""
def __init__(self, viewport, source=None):
def __init__(self, viewport, source=None, display_seed=None):
self.viewport = viewport
self.source = source or ProjectionLayoutSource()
self.display_seed = display_seed
self._stamp = None
self._lock = threading.Lock()
self._pending = None
@@ -71,7 +90,7 @@ class ProjectionLayoutWatcher:
def load(self):
self._stamp = self._current_stamp()
return load_projection_layout(self.viewport, self.source)
return load_projection_layout(self.viewport, self.source, self.display_seed)
def poll(self):
"""Background thread only: the new layout after a save, or None. An unreadable file is retried."""
@@ -86,7 +105,7 @@ class ProjectionLayoutWatcher:
return None
self._stamp = stamp
if raw is None:
return default_layout_for_viewport(self.viewport)
return _unsaved_layout(self.viewport, self.display_seed)
try:
return decode_layout_for_viewport(raw, self.viewport)
except (ValueError, TypeError, UnicodeError, OverflowError, RecursionError):
@@ -12,6 +12,7 @@ from openpilot.starpilot.system.android_auto.projection_geometry import FALLBACK
CERTIFICATE_NOTICE_NS = 10_000_000_000 # how long the expiry heads-up stays at the start of a drive
DRIVER_MONITOR_HOLD_NS = 350_000_000
STANDBY_LOGO = Path(__file__).parents[3] / 'selfdrive/assets/images/starpilot_logo.png'
BUBBLE_REDUNDANT_ALERTS = frozenset(('preLaneChangeLeft', 'preLaneChangeRight', 'laneChange',
'laneChangeBlocked', 'laneChangeBlockedLoud'))
@@ -80,6 +81,23 @@ def native_dependencies():
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
rl.set_shader_value(self.shader, self._road_camera_loc, self._road_camera_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
def standby_logo(height):
"""The StarPilot crescent, shrunk once on the CPU to its standby size; None if it cannot be loaded."""
image = rl.load_image(str(STANDBY_LOGO))
try:
if image.data == rl.ffi.NULL or image.width <= 0 or image.height <= 0:
return None
if image.height > height:
rl.image_resize(image, round(image.width * height / image.height), height)
texture = rl.load_texture_from_image(image)
finally:
if image.data != rl.ffi.NULL:
rl.unload_image(image)
if texture.id == 0:
return None
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
return texture
return SimpleNamespace(rl=rl, ui_state=ui_state, camera=ProjectionRoadCamera,
onroad=OnroadView, monitor=DriverMonitorLayer, fonts=BitmapFonts,
font_role=FontRole, profile=Profile, font_directory=default_font_directory,
@@ -87,7 +105,7 @@ def native_dependencies():
shell_mode=ShellMode, pip_renderer=PiPRenderer, read_pip=read_pip, pip_signals=Signals,
pip_rect=Rect, pip_widgets=CAMERA_WIDGETS, placement=placement, widget_size=widget_size,
alert=OnroadAlert, alert_size=AlertSize, map_overlay=MapOverlay, map_feed=MapFeed,
favorites=ProjectionFavorites)
favorites=ProjectionFavorites, standby_logo=standby_logo)
class ProjectionOnroad:
@@ -110,6 +128,8 @@ class ProjectionOnroad:
self.camera_stream = None # the camera stream the last frame drew, or None
self.native = dependencies or native_dependencies()
native = self.native
self._standby_logo = None
self._standby_logo_tried = False
if native.ui_state.projection_read_only is not True:
raise RuntimeError('Projection UIState must be read-only')
self._resources = ExitStack()
@@ -177,7 +197,11 @@ class ProjectionOnroad:
self.onroad.close()
def _camera_layer(self, rect, state):
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'])
# The car screen's own lead choices; without them the marker stays off rather than following the comma.
appearance = getattr(state, 'appearance', None)
lead_display = ((appearance.show_lead_indicator, int(appearance.lead_info_mode), appearance.lead_info_metric)
if appearance is not None else (False, 0, None))
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'], lead_display=lead_display)
# The camera can switch narrow/wide while rendering. Pace the stream actually
# drawn, not the previous selection (whose next frame may arrive later).
self.camera_stream = self.camera.stream_type
@@ -321,11 +345,39 @@ class ProjectionOnroad:
def _standby(self):
rl = self.native.rl
rl.draw_rectangle(0, 0, int(self.width), int(self.height), rl.Color(12, 26, 34, 255))
rl.draw_rectangle_rounded(rl.Rectangle(120, 140, self.width - 240, self.height - 280), 0.06, 16, rl.Color(18, 40, 57, 255))
rl.draw_rectangle_rounded_lines_ex(rl.Rectangle(120, 140, self.width - 240, self.height - 280), 0.06, 16, 4, rl.Color(50, 126, 144, 255))
self.fonts.draw('StarPilot', self.native.font_role.BRAND, 112, 220, 325)
self.fonts.draw('Ready when driving', self.native.font_role.MEDIUM, 55, 225, 475)
purple = rl.Color(188, 132, 255, 255) # StarPilot purple, as on the comma's moon
rl.draw_rectangle(0, 0, int(self.width), int(self.height), rl.Color(10, 10, 22, 255))
panel_height = self.height - 280
panel = rl.Rectangle(120, 140, self.width - 240, panel_height)
rl.draw_rectangle_rounded(panel, 0.06, 16, rl.Color(26, 16, 46, 255))
rl.draw_rectangle_rounded_lines_ex(panel, 0.06, 16, 4, purple)
logo = self._standby_texture(int(panel_height * 0.6))
text_x = 220
if logo is not None:
rl.draw_texture(logo, 200, int((self.height - logo.height) / 2), rl.WHITE)
text_x = 200 + logo.width + 70
middle = self.height / 2
self.fonts.draw('StarPilot', self.native.font_role.BRAND, 112, text_x, middle - 130, purple)
self.fonts.draw('Ready when driving', self.native.font_role.MEDIUM, 55, text_x + 5, middle + 20, rl.Color(222, 204, 255, 255))
def _standby_texture(self, height):
"""Load the logo the first time standby shows; a missing logo leaves the text-only screen."""
if not self._standby_logo_tried:
self._standby_logo_tried = True
load = getattr(self.native, 'standby_logo', None)
if load is not None:
try:
self._standby_logo = load(height)
except Exception:
self._standby_logo = None
if self._standby_logo is not None:
self._resources.callback(self._unload_standby_logo)
return self._standby_logo
def _unload_standby_logo(self):
if self._standby_logo is not None:
self.native.rl.unload_texture(self._standby_logo)
self._standby_logo = None
def render(self):
ui = self.native.ui_state
@@ -237,12 +237,25 @@ def test_import_stops_when_master_switch_is_disabled(monkeypatch, tmp_path):
assert not source.exists()
def test_import_job_labels_metadata_free_package_with_offered_version(tmp_path, ident, monkeypatch):
installed = {}
monkeypatch.setattr(apk_identity, "install_identity", lambda files, metadata, directory: installed.update(metadata))
for path, hint, expected in ((make_xapk(tmp_path, ident), "16.2.1-release", "16.2.1-release"), (make_apk(tmp_path, ident), None, None)):
installed.clear()
job = apk_identity.ImportJob(work_dir=tmp_path, root_sha256=ident["root_sha"])
job.start(path=path, app_version=hint)
job.thread.join(timeout=5)
assert job.status()["state"] == "done" and installed.get("app_version") == expected
def test_status_reports_expiry(tmp_path):
cert, root, key_pem, _ = build_identity(days=5)
directory = tmp_path / "identity"
apk_identity.install_identity({"phone-cert.pem": cert, "phone-key.pem": key_pem, "root-cert.pem": root}, {"subject": "O=CarService"}, directory)
apk_identity.install_identity({"phone-cert.pem": cert, "phone-key.pem": key_pem, "root-cert.pem": root},
{"subject": "O=CarService", "app_version": "16.2.1-release"}, directory)
status = apk_identity.identity_status(directory)
assert status["installed"] and status["days_left"] in (4, 5) and "renew" in status["warning"]
assert status["app_version"] == "16.2.1-release"
def test_certificate_days_left_reads_only_the_certificate(tmp_path):
@@ -112,6 +112,7 @@ def cache(monkeypatch):
monkeypatch.setattr(apkpure, "_cache", None)
monkeypatch.setattr(apkpure, "_refresh_after", 0.0)
monkeypatch.setattr(apkpure, "_in_flight", False)
monkeypatch.setattr(apkpure, "_sleep", lambda delay: None)
def run_refresh(monkeypatch, result, clock):
@@ -141,7 +142,7 @@ def test_cache_ttls_follow_link_expiry_and_failures(monkeypatch, cache):
assert apkpure.download_status(now=NOW + 3601) is None
assert run_refresh(monkeypatch, None, clock) == [] # still fresh
clock[0] = apkpure._refresh_after
assert run_refresh(monkeypatch, None, clock) == [1]
assert run_refresh(monkeypatch, None, clock) == [1, 1, 1] # three tries before giving up
assert apkpure.download_status(now=NOW) is None
assert apkpure._refresh_after == pytest.approx(clock[0] + apkpure.FAILURE_TTL)
clock[0] += apkpure.FAILURE_TTL
@@ -149,6 +150,29 @@ def test_cache_ttls_follow_link_expiry_and_failures(monkeypatch, cache):
assert apkpure._refresh_after == pytest.approx(clock[0] + apkpure.SUCCESS_TTL)
def test_refresh_retries_before_giving_up_and_reports_pending(monkeypatch, cache):
monkeypatch.setattr(apkpure, "_now", lambda: NOW)
good = {"version": "17.7.663654-release", "code": 1, "url": LINK, "size": 5, "expires": NOW + 2 * 86400}
results, waits, pending = [None, good], [], []
def fetch():
pending.append(apkpure.lookup_pending())
return results.pop(0)
monkeypatch.setattr(apkpure, "fetch_latest", fetch)
monkeypatch.setattr(apkpure, "_sleep", waits.append)
class Inline:
def __init__(self, target, **kwargs): self.target = target
def start(self): self.target()
monkeypatch.setattr(apkpure.threading, "Thread", Inline)
assert not apkpure.lookup_pending()
apkpure.start_background_refresh(now=lambda: 0.0)
assert waits == [apkpure.RETRY_DELAYS[0]]
assert pending == [True, True] # the page waits while tries remain
assert not apkpure.lookup_pending()
status = apkpure.download_status()
assert status is not None
assert status["url"] == LINK
def test_refresh_is_single_flight(monkeypatch, cache):
entered, release_fetch = threading.Event(), threading.Event()
calls = []
@@ -164,4 +188,11 @@ def test_refresh_is_single_flight(monkeypatch, cache):
for thread in threading.enumerate():
if thread.name == "aa-apkpure":
thread.join(2)
assert calls == [1]
assert calls == [1, 1, 1] # one lookup's three tries; the second start didn't add another
def test_version_for_size_names_only_an_exact_size_match(monkeypatch, cache):
assert apkpure.version_for_size(5) is None
monkeypatch.setattr(apkpure, "_cache", {"version": "17.7.663654-release", "code": 1, "url": LINK, "size": 5, "expires": NOW})
assert apkpure.version_for_size(5) == "17.7.663654-release" # an expired link still names the file it pointed to
assert apkpure.version_for_size(6) is None
@@ -3,11 +3,11 @@ import ast
from pathlib import Path
import tempfile
from types import SimpleNamespace as NS
from typing import Any, cast
from unittest.mock import Mock, patch
import pytest
from openpilot.starpilot.favorites.actions import BOOKMARK
from openpilot.starpilot.system.android_auto.projection_bookmark import BookmarkReceiver, BookmarkSender, MAX_AGE_NS
@@ -30,40 +30,42 @@ def native_poll():
source = Path(__file__).parents[3] / 'ui/runtime_app.py'
tree = ast.parse(source.read_text())
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == '_poll_projection_bookmark')
namespace = {'BOOKMARK': BOOKMARK, 'ui_state': NS(started=True, started_frame=1, sm=NS(logMonoTime={'carState': 50})),
'current_message': Mock(return_value=object())}
namespace: dict[str, Any] = {'ui_state': NS(started=True, started_frame=1, sm=NS(logMonoTime={'carState': 50})),
'display_message': Mock(return_value=object()), 'cast': cast}
exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace)
return namespace
def test_request_dispatches_only_the_native_bookmark_action(transport):
def test_request_publishes_a_bookmark_whatever_the_comma_screen_shows(transport):
# The comma screen's alerts and page gate taps on that screen, not taps on the car's.
receiver, sender = transport
namespace = native_poll()
invoke = Mock(return_value=True)
session = NS(_projection_bookmarks=receiver, _favorite_authority=lambda: True,
_native_favorite_actions=lambda: {BOOKMARK: NS(available=True, invoke=invoke)})
publish = Mock()
session = NS(_projection_bookmarks=receiver, _publish_bookmark=publish,
_favorite_authority=Mock(side_effect=AssertionError('the comma screen does not gate car taps')))
with patch('time.monotonic_ns', return_value=100):
assert sender.send(50)
namespace['_poll_projection_bookmark'](session, 101)
invoke.assert_called_once_with()
publish.assert_called_once_with()
namespace['_poll_projection_bookmark'](session, 102)
invoke.assert_called_once_with()
publish.assert_called_once_with()
@pytest.mark.parametrize('block', ['offroad', 'authority', 'stale_car', 'unavailable_action'])
def test_native_ui_rechecks_authority_and_consumes_blocked_requests(transport, block):
@pytest.mark.parametrize('block', ['offroad', 'stale_car', 'replay', 'no_publisher'])
def test_native_ui_consumes_requests_it_cannot_bookmark(transport, block):
receiver, sender = transport
namespace = native_poll()
invoke = Mock()
session = NS(_projection_bookmarks=receiver, _favorite_authority=lambda: block != 'authority',
_native_favorite_actions=lambda: {BOOKMARK: NS(available=block != 'unavailable_action', invoke=invoke)})
publish = Mock()
session = NS(_projection_bookmarks=receiver, _publish_bookmark=None if block == 'no_publisher' else publish)
namespace['ui_state'].started = block != 'offroad'
if block == 'replay':
namespace['ui_state'].replay_clock = object()
if block == 'stale_car':
namespace['current_message'].return_value = None
namespace['display_message'].return_value = None
with patch('time.monotonic_ns', return_value=100):
assert sender.send(50)
namespace['_poll_projection_bookmark'](session, 101)
invoke.assert_not_called()
publish.assert_not_called()
assert receiver.drain(102, 50, authorized=True) == 0
@@ -18,6 +18,33 @@ SCREEN = {'version': 1, 'width': 1280, 'height': 720, 'margin_width': 0,
class TestProjectionLayout(unittest.TestCase):
def test_exit_defaults_and_monitor_scaling_preserve_saved_appearance(self):
document = default_layout(SCREEN)
metadata = layout_metadata(SCREEN)
self.assertEqual(document['widgets'][CAR_EXIT]['opacity'], 75)
self.assertIs(document['widgets'][CAR_EXIT]['background'], False)
self.assertIs(document['widgets'][CAR_EXIT]['enabled'], True)
self.assertEqual(metadata['widgets'][CAR_EXIT]['opacity']['default'], 75)
document['widgets'][CAR_EXIT].update(opacity=60, background=True)
monitor = document['widgets']['driver_monitor']
self.assertEqual(monitor['size'], 128)
monitor.update(x=700, y=600)
del monitor['size']
restored = validate_layout(document, SCREEN)
self.assertEqual(restored['widgets']['driver_monitor'], {'x': 732, 'y': 632, 'enabled': True, 'size': 128})
self.assertEqual(restored['widgets'][CAR_EXIT], document['widgets'][CAR_EXIT])
self.assertEqual(validate_layout(restored, SCREEN), restored)
for size in (64, 192):
restored['widgets']['driver_monitor']['size'] = size
decoded = decode_layout(json.dumps(restored).encode(), SCREEN)
projected = projection_customization(decoded, default_document())
self.assertEqual(projected['layouts']['large']['driver_monitor']['size'], size)
self.assertEqual(placement_size('driver_monitor', metadata['widgets']['driver_monitor'], decoded['widgets']['driver_monitor']), (size, size))
for size in (63, 193, True, 128.5):
restored['widgets']['driver_monitor']['size'] = size
with self.assertRaises(ValueError):
validate_layout(restored, SCREEN)
def test_stopped_timer_migrates_and_keeps_independent_placement_and_order(self):
document = default_layout(SCREEN)
metadata = layout_metadata(SCREEN)
@@ -103,7 +130,7 @@ class TestProjectionLayout(unittest.TestCase):
document = default_layout(SCREEN)
self.assertIs(document['blindSpotBorder'], False)
self.assertIs(document['signalBorder'], False)
for key in ('blindSpotBorder', 'signalBorder', 'hidePersonalityAlerts', 'wheelPedalFeedback'):
for key in ('blindSpotBorder', 'signalBorder', 'hidePersonalityAlerts', 'wheelPedalFeedback', 'leadIndicator'):
older = {k: v for k, v in document.items() if k != key}
self.assertEqual(validate_layout(older, SCREEN)[key], document[key], 'older layouts get the defaults')
flipped = validate_layout({**document, key: not document[key]}, SCREEN)
@@ -112,6 +139,17 @@ class TestProjectionLayout(unittest.TestCase):
with self.subTest(key=key, value=value), self.assertRaises(ValueError):
validate_layout({**document, key: value}, SCREEN)
def test_lead_info_starts_off_and_takes_only_known_choices(self):
document = default_layout(SCREEN)
self.assertEqual((document['leadIndicator'], document['leadInfo']), (False, 'Off'))
older = {k: v for k, v in document.items() if k not in ('leadIndicator', 'leadInfo')}
self.assertEqual(validate_layout(older, SCREEN), document, 'older layouts get the lead defaults')
for choice in ('Off', 'Distance', 'Speed'):
self.assertEqual(validate_layout({**document, 'leadInfo': choice}, SCREEN)['leadInfo'], choice)
for value in ('speed', 'Both', 1, True, None, [], {}):
with self.subTest(value=value), self.assertRaises(ValueError):
validate_layout({**document, 'leadInfo': value}, SCREEN)
def test_blind_spot_camera_migrates_and_survives_serialization(self):
document = default_layout(SCREEN)
self.assertIs(document.pop('blindSpotCamera'), False)
@@ -162,6 +200,38 @@ class TestProjectionLayout(unittest.TestCase):
with self.assertRaises(ValueError):
decode_layout(b'{"version":1,"version":1}', SCREEN)
def test_cruise_background_opacity_and_sources_are_android_auto_settings(self):
from openpilot.starpilot.system.android_auto.projection_layout import (
default_layout_for_viewport, projection_customization, validate_layout_for_viewport)
from openpilot.starpilot.ui.onroad_customization import rgba
layout = default_layout_for_viewport((1920, 1080))
self.assertEqual(layout['widgets']['cruise_limits']['opacity'], 100)
self.assertIs(layout['speedSources'], False)
self.assertEqual(layout['speedAlign'], 'left')
base = default_document()
solid = rgba(base, 'cardFill', 'large', 'cruise_limits')
native = projection_customization(layout, base)
self.assertEqual(rgba(native, 'cardFill', 'large', 'cruise_limits'), solid)
self.assertNotIn('opacity', native['layouts']['large']['cruise_limits'])
layout['widgets']['cruise_limits']['opacity'] = 40
layout['speedSources'] = True
native = projection_customization(validate_layout_for_viewport(layout, (1920, 1080)), base)
self.assertEqual(rgba(native, 'cardFill', 'large', 'cruise_limits'), (*solid[:3], round(solid[3] * .4)))
self.assertEqual(rgba(native, 'text', 'large', 'cruise_limits'), rgba(base, 'text', 'large', 'cruise_limits'))
self.assertIs(native['speedSources'], True)
saved = copy.deepcopy(layout)
del saved['widgets']['cruise_limits']['opacity']
del saved['speedSources']
restored = validate_layout_for_viewport(saved, (1920, 1080))
self.assertEqual((restored['widgets']['cruise_limits']['opacity'], restored['speedSources']), (100, False))
self.assertEqual(projection_customization(validate_layout_for_viewport({**layout, 'speedAlign': 'right'}, (1920, 1080)), base)['speedAlign'], 'right')
with self.assertRaises(ValueError):
validate_layout_for_viewport({**layout, 'speedAlign': 'justify'}, (1920, 1080))
for bad in (-1, 101, 50.5, True):
layout['widgets']['cruise_limits']['opacity'] = bad
with self.assertRaises(ValueError):
validate_layout_for_viewport(layout, (1920, 1080))
def test_projection_offsets_are_applied_once_and_separate(self):
base = default_document()
before = copy.deepcopy(base)
@@ -77,6 +77,28 @@ class TestProjectionLayoutRuntime(unittest.TestCase):
self.assertEqual(load_projection_layout(viewport, source), defaults)
self.assertEqual(source.path.read_bytes(), raw)
def test_unsaved_layout_seeds_comma_display_choices(self):
with tempfile.TemporaryDirectory() as directory:
source = ProjectionLayoutSource(Path(directory) / 'layouts/document.json')
source.prepare()
viewport = (2880, 1080)
seed = {'signalBorder': True, 'leadIndicator': True, 'leadInfo': 'Speed', 'unknown': True}
loaded = load_projection_layout(viewport, source, lambda: seed)
self.assertEqual(loaded, default_layout_for_viewport(viewport, seed))
self.assertTrue(loaded['signalBorder'])
self.assertEqual(loaded['leadInfo'], 'Speed')
self.assertNotIn('unknown', loaded)
def broken():
raise OSError('params unreadable')
self.assertEqual(load_projection_layout(viewport, source, broken), default_layout_for_viewport(viewport))
source.path.write_bytes(b'bad')
self.assertEqual(load_projection_layout(viewport, source, lambda: seed), default_layout_for_viewport(viewport),
'a corrupt saved layout is not a first layout')
watcher = ProjectionLayoutWatcher(viewport, source, lambda: seed)
watcher.load()
source.path.unlink()
self.assertTrue(watcher.poll()['signalBorder'])
def test_saved_layout_loaded_once_and_next_connection_observes_save(self):
with tempfile.TemporaryDirectory() as directory:
source = ProjectionLayoutSource(Path(directory) / 'layouts/document.json')
@@ -285,6 +307,35 @@ class TestProjectionLayoutRuntime(unittest.TestCase):
self.assertEqual((rendered[-1].appearance.hide_personality_alerts, rendered[-1].appearance.wheel_pedal_feedback), (True, True))
self.assertIs(adapter.appearance_override(OnroadAppearance()).hide_personality_alerts, True)
def test_lead_marker_and_info_follow_the_aa_layout_only(self):
from openpilot.starpilot.ui.appearance_preferences import LeadInfoMode, OnroadAppearance
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation
helper = boundary.TestProjectionOnroad()
native, _ = helper.dependencies()
native.ui_state.started = True
comma = OnroadAppearance(show_lead_indicator=True, lead_info_mode=LeadInfoMode.DISTANCE, lead_info_metric=False)
state = OnroadState(False, False, None, None, SpeedLimitObservation(), customization=default_document(), appearance=comma)
rendered, painted = [], []
native.adapter = lambda *_: SimpleNamespace(build=lambda *_a, **_k: SimpleNamespace(onroad=state))
layout = default_layout_for_viewport((2880, 1080))
view = projection.ProjectionOnroad(dependencies=native, viewport=(2880, 1080), customization=layout)
self.addCleanup(view.close)
view.onroad.render = rendered.append
view.camera.render_camera_model_layer = lambda _rect, **kwargs: painted.append(kwargs['lead_display'])
view.camera.stream_type = None
def lead(document):
view.apply_layout(document)
view.render()
view._camera_layer(None, rendered[-1])
return rendered[-1].appearance.show_lead_indicator, rendered[-1].appearance.lead_info_mode, painted[-1]
self.assertEqual(lead(layout), (False, LeadInfoMode.OFF, (False, 0, False)), 'off on the car even with the comma on')
self.assertEqual(lead({**layout, 'leadInfo': 'Speed'}), (False, LeadInfoMode.OFF, (False, 0, False)), 'info needs the marker')
self.assertEqual(lead({**layout, 'leadIndicator': True}), (True, LeadInfoMode.OFF, (True, 0, False)))
self.assertEqual(lead({**layout, 'leadIndicator': True, 'leadInfo': 'Speed'}), (True, LeadInfoMode.SPEED, (True, 2, False)))
self.assertEqual(lead({**layout, 'leadIndicator': True, 'leadInfo': 'Distance'}), (True, LeadInfoMode.DISTANCE, (True, 1, False)))
def test_real_onroad_state_unchanged_without_projection_document(self):
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation
helper = boundary.TestProjectionOnroad()
@@ -21,6 +21,7 @@ spec.loader.exec_module(projection)
class FakeGraphics:
BLACK = object()
WHITE = object()
@staticmethod
def Color(*values): return values
@@ -37,6 +38,9 @@ class FakeGraphics:
@staticmethod
def draw_rectangle_rounded_lines_ex(*_): pass
@staticmethod
def draw_texture(*_): pass
@staticmethod
def unload_texture(*_): pass
@@ -150,6 +154,30 @@ class TestProjectionOnroad(unittest.TestCase):
self.assertEqual(events[-3:], ['view_closed', 'camera_closed', 'fonts_closed'])
self.assertEqual(native.ui_state._offroad_transition_callbacks, [])
def test_standby_logo_loads_once_and_is_freed_on_close(self):
native, _ = self.dependencies()
logo = SimpleNamespace(width=300, height=250)
heights, drawn, unloaded = [], [], []
native.standby_logo = lambda height: heights.append(height) or logo
native.rl = SimpleNamespace(**{name: getattr(FakeGraphics, name) for name in dir(FakeGraphics) if not name.startswith('_')})
native.rl.draw_texture = lambda texture, *_: drawn.append(texture)
native.rl.unload_texture = unloaded.append
view = projection.ProjectionOnroad(dependencies=native)
view.render()
view.render()
self.assertEqual(heights, [576])
self.assertEqual(drawn, [logo, logo])
view.close()
self.assertEqual(unloaded, [logo])
def test_standby_without_logo_still_draws_text(self):
native, events = self.dependencies()
native.standby_logo = lambda _height: None
view = projection.ProjectionOnroad(dependencies=native)
self.addCleanup(view.close)
view.render()
self.assertEqual(events.count('draw'), 2)
def test_side_camera_layer_uses_blinkers_saved_preview_and_projection_placements(self):
native, events = self.dependencies()
calls = []
@@ -2,10 +2,12 @@
from dataclasses import dataclass
from enum import IntEnum
import math
import os
import stat
from typing import Any
from openpilot.common.constants import CV
from openpilot.starpilot.saved_source import read_saved
@@ -29,6 +31,22 @@ class LeadInfoMode(IntEnum):
LEAD_INFO_LABELS = ("Off", "Distance", "Speed")
def format_lead_info(lead, mode: int, metric: bool | None) -> str:
"""The lead's distance or speed for the text above the lead marker; empty when there is nothing to show."""
if lead is None or not lead.present or mode not in (LeadInfoMode.DISTANCE, LeadInfoMode.SPEED) or metric is None:
return ""
try:
value = float(lead.dRel if mode == LeadInfoMode.DISTANCE else lead.vLead)
except (AttributeError, TypeError, ValueError, OverflowError):
return ""
if not math.isfinite(value):
return ""
value = max(value, 0.0)
if mode == LeadInfoMode.DISTANCE:
return f"{round(value if metric else value * 3.28084)} {'m' if metric else 'ft'}"
return f"{round(value * (CV.MS_TO_KPH if metric else CV.MS_TO_MPH))} {'km/h' if metric else 'mph'}"
@dataclass(frozen=True)
class SavedCameraView:
raw: bytes | None
@@ -15,6 +15,8 @@ class OnroadVisualPreview:
cem_reason: str | None = None
curve_curvature: float | None = None
label: str = "SYNTHETIC REPLAY PREVIEW"
# The Galaxy layout editor's sample: draw exactly as the car would, without the replay labels.
layout_sample: bool = False
def parse_flags(raw: str) -> frozenset[str]:
+7 -89
View File
@@ -1,7 +1,6 @@
"""Large speed stack dimensions shared by rendering, input and layout metadata."""
from functools import lru_cache
WIDTH = 344
WIDTH = 320
MAX_ROW_HEIGHT = 210
LIMIT_ROW_HEIGHT = 260
CARD_HEIGHT = MAX_ROW_HEIGHT + LIMIT_ROW_HEIGHT
@@ -10,8 +9,7 @@ ACTION_X = 88
ACTION_Y = 75 + CARD_HEIGHT + 22
ACTION_HEADER_HEIGHT = 44
GAP = 8
TOGGLE_HEIGHT = 44
SOURCE_ROW_HEIGHT = 56
SOURCE_ROW_HEIGHT = 76
PADDING = 24
@@ -19,88 +17,8 @@ def card_height(mode):
return CARD_HEIGHT if mode == 'split' else LIMIT_ROW_HEIGHT if mode == 'limit_only' else MAX_ROW_HEIGHT
@lru_cache(maxsize=8)
def _projection_widgets(width, height):
from openpilot.starpilot.system.android_auto.projection_layout import layout_metadata_for_viewport
return layout_metadata_for_viewport((width, height))['widgets']
def _action_regions(state):
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.onroad_state import slc_controls
from openpilot.starpilot.ui.presentation import Profile
header = ACTION_HEADER_HEIGHT if state.speed_limit.pending_speed_limit_mps is not None or state.speed_limit.action_feedback else 0
regions = [(l, t - header, r, b) for l, t, r, b in
(control.bounds for control in slc_controls(Profile.LARGE, state))]
saved = placement(state.customization, 'large', 'speed_limit_actions')
if not regions and state.speed_limit.action_feedback and saved['enabled']:
regions.append((saved['x'], saved['y'], saved['x'] + WIDTH, saved['y'] + ACTION_HEADER_HEIGHT))
return regions
def source_toggle_bounds(state):
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
card = placement(state.customization, 'large', 'cruise_limits')
shown = resolve_unified_speed(state)
height = card_height(shown.mode)
top = card['y'] + height + 22
for left, action_top, right, bottom in _action_regions(state):
if left < card['x'] + WIDTH and right > card['x'] and action_top < top + TOGGLE_HEIGHT and bottom > top:
top = bottom + 22
if top + TOGGLE_HEIGHT > 1050:
top = card['y'] - GAP - TOGGLE_HEIGHT
return card['x'], top, card['x'] + WIDTH, top + TOGGLE_HEIGHT
def source_panel_bounds(state, height, viewport):
"""Prefer the aligned column; move the popup only for constrained saved layouts."""
from openpilot.starpilot.ui.onroad_customization import placement, PROFILES, widget_size, CAMERA_WIDGETS
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
card = placement(state.customization, 'large', 'cruise_limits')
height_of_card = card_height(resolve_unified_speed(state).mode)
left, top, right, bottom = source_toggle_bounds(state)
occupied = [(card['x'], card['y'], card['x'] + WIDTH, card['y'] + height_of_card), (left, top, right, bottom)]
occupied.extend(_action_regions(state))
layouts = state.customization['layouts']['large']
projected = 'nav_card' in layouts
if projected:
from openpilot.starpilot.system.android_auto.projection_layout import placement_size
widgets = _projection_widgets(round(viewport.width + 60), round(viewport.height + 60))
else:
widgets = PROFILES['large']['widgets']
for key, widget in widgets.items():
if key in ('cruise_limits', 'speed_limit_actions', *CAMERA_WIDGETS):
continue
saved = layouts.get(key)
if saved is None:
continue
if saved['enabled']:
if key == 'cem_gauge':
from openpilot.starpilot.ui.onroad_cem_gauge import gauge_data
if gauge_data(state) is None:
continue
width, widget_height = (placement_size(key, widget, saved) if projected else widget_size(state.customization, 'large', key))
native = PROFILES['large']['widgets'].get(key)
dx = widget['default']['x'] - native['default']['x'] if projected and native else 0
dy = widget['default']['y'] - native['default']['y'] if projected and native else 0
x, y = saved['x'] + dx, saved['y'] + dy
occupied.append((x, y, x + width, y + widget_height))
candidates = [(left, bottom + GAP), (left, top - GAP - height),
(left, card['y'] - GAP - height), (right + GAP, top), (left - GAP - WIDTH, top),
(right + GAP, top - GAP - height), (left - GAP - WIDTH, top - GAP - height)]
# Keep the aligned choices first; another saved widget can fill that column.
# Its edges supply further columns using the same popup gap and clip checks.
for occupied_left, _, occupied_right, _ in occupied:
candidates.extend(((occupied_right + GAP, top), (occupied_left - GAP - WIDTH, top),
(occupied_right + GAP, top - GAP - height), (occupied_left - GAP - WIDTH, top - GAP - height)))
for x, y in candidates:
y = min(max(y, viewport.y), viewport.y + viewport.height - height)
if not viewport.x <= x <= viewport.x + viewport.width - WIDTH:
continue
if all(x + WIDTH <= l or x >= r or y + height <= t or y >= b for l, t, r, b in occupied):
return x, y, WIDTH, height
return None
def sources_extent(document):
"""Extra height the cruise widget takes for speed-limit sources: a row in the card, or a small card of its own below."""
if document.get('speedSources') is not True:
return 0
return GAP + SOURCE_ROW_HEIGHT if document.get('speedSourcesCard') is True else SOURCE_ROW_HEIGHT
@@ -21,7 +21,7 @@ from openpilot.starpilot.curve_speed.status import CurveObservation
from openpilot.starpilot.ui.onroad_state import ConditionalLead
from openpilot.starpilot.ui.onroad_state import AlertSize, ObservationKind, OnroadAlert, OnroadState, SourceReading, SpeedLimitObservation
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance
from openpilot.starpilot.ui.onroad_dm import monitor_visible
from openpilot.starpilot.ui.onroad_dm import draw_monitor_art, monitor_bounds, monitor_visible
from openpilot.starpilot.ui.onroad_stopped_timer import PREVIEW_DURATION_S
from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_sidebar
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory
@@ -67,9 +67,10 @@ def render_sample_road(rect: rl.Rectangle, state: OnroadState, profile: Profile)
def sample_state(scene: str, document: dict) -> OnroadState:
if scene not in SCENES:
raise ValueError("Unknown layout preview scene")
lateral = scene in ("engaged", "aol", "experimental", "braking", "stopped", "slc_pending") or scene in CEM_SCENE_REASONS
longitudinal = scene in ("engaged", "long_only", "experimental", "braking", "stopped", "slc_pending") or scene in CEM_SCENE_REASONS
longitudinal = longitudinal or scene in ('slc_max', 'slc_equal', 'slc_pedal', 'slc_retained')
# SLC presentation changes do not disengage either driving axis. Only the explicit
# cruise-off/lateral-only/longitudinal-only scenes change engagement.
lateral = scene not in ('long_only', 'slc_disengaged')
longitudinal = scene not in ('aol', 'slc_disengaged')
state = OnroadState(engaged=lateral or longitudinal, camera_available=False,
speed_mps=0.0 if scene in ("cem_lead", "stopped") else 22.0, cruise_kph=88.0,
stopped_duration_s=PREVIEW_DURATION_S if scene == "stopped" else None,
@@ -93,7 +94,7 @@ def sample_state(scene: str, document: dict) -> OnroadState:
longitudinal_active=longitudinal, stock_cruise_active=False,
slc_system_long_available=longitudinal, torque_utilization=0.78 if lateral else 0.0,
customization=document,
visual_preview=OnroadVisualPreview(cem_reason=CEM_SCENE_REASONS[scene]) if scene in CEM_SCENE_REASONS else None)
visual_preview=OnroadVisualPreview(cem_reason=CEM_SCENE_REASONS[scene], layout_sample=True) if scene in CEM_SCENE_REASONS else None)
if scene == 'cem_lead':
state = replace(state, conditional_lead=ConditionalLead(12.0, 0.0))
@@ -180,17 +181,11 @@ class _DriverMonitorArt:
raise
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False) -> None:
position = placement(state.customization, self.profile, "driver_monitor")
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons):
return
size = 128 if self.profile == Profile.LARGE else 60
x = position["x"] + (192 - size) / 2 if self.profile == Profile.LARGE else position["x"]
y = position["y"] + (192 - size) / 2 if self.profile == Profile.LARGE else position["y"]
self.renderer._rect = rl.Rectangle(x, y, size, size)
widget = PROFILES[str(self.profile)]["widgets"]["driver_monitor"]
draw_widget_frame(rl.Rectangle(position["x"], position["y"], widget["width"], widget["height"]),
state.customization, self.profile, "driver_monitor")
self.renderer._render(self.renderer._rect)
bounds = monitor_bounds(state.customization, self.profile, False)
draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor")
draw_monitor_art(self.renderer, bounds, self.profile)
def close(self) -> None:
if rl.is_window_ready():
@@ -266,8 +261,8 @@ class _Canvas:
rl.draw_circle_v(center, radius, rl.Color(21, 44, 57, 230))
rl.draw_ring(center, radius - 3, radius, 0, 360, 64, rl.Color(94, 229, 238, 255))
def _render_driver_monitor(self, rect: rl.Rectangle, state: OnroadState) -> None:
top_icons = (self.profile == Profile.COMPACT and state.alert.size == AlertSize.NONE and
def _render_driver_monitor(self, rect: rl.Rectangle, state: OnroadState, *, general: bool = False) -> None:
top_icons = (not general and self.profile == Profile.COMPACT and state.alert.size == AlertSize.NONE and
state.appearance.camera_view != CameraViewChoice.DRIVER and not state.reverse_driver_camera and
self.view.compact_hud._set_speed_alpha.x > 0.01 and
placement(state.customization, self.profile, "max_speed")["enabled"])
@@ -294,9 +289,11 @@ class _Canvas:
try:
rl.clear_background(rl.BLACK)
self.view.render(state)
if self.viewport is not None:
# ProjectionOnroad adds this permanent brand after the shared onroad view.
self.fonts.draw('StarPilot', FontRole.BRAND, 30, width - 210, height - 90)
if self.profile == Profile.LARGE and self.viewport is None:
render_sidebar(self.fonts, rl.Rectangle(width - SIDEBAR_WIDTH, 0, SIDEBAR_WIDTH, height))
self._label(scene)
finally:
rl.end_texture_mode()
image = rl.load_image_from_texture(self.target.texture)
@@ -311,13 +308,16 @@ class _Canvas:
finally:
rl.unload_image(image)
def render_widget_art(self, state: OnroadState, positions: dict) -> bytes:
def render_widget_art(self, state: OnroadState, positions: dict, *, general: bool = False) -> bytes:
"""Transparent arrangement art from the same widgets/fonts as the live view."""
if self.view is None or self.fonts is None or self.target is None:
raise RuntimeError("Layout preview resources are closed")
self._settle(state)
canvas_width, canvas_height = self.viewport or self.profile.size
content = (rl.Rectangle(30, 30, canvas_width - 60, canvas_height - 60) if self.profile == Profile.LARGE
# Native Large has a 300px sidebar outside the onroad viewport. Including it
# here shifts right-anchored artwork beyond its saved crop (notably the wheel).
road_width = self.viewport[0] if self.viewport else state.viewport_width
content = (rl.Rectangle(30, 30, road_width - 60, canvas_height - 60) if self.profile == Profile.LARGE
else rl.Rectangle(0, 0, 476, 240))
sprites = {}
# A bounded subset covers the edited widgets without adding an onroad render loop.
@@ -328,6 +328,15 @@ class _Canvas:
position = positions[key]
crop_x, crop_y = math.floor(position['x']), math.floor(position['y'])
offset_x, offset_y = crop_x - position['x'], crop_y - position['y']
if key == 'speed_limit_actions' and self.profile == Profile.COMPACT:
# The native pending-limit heading sits above the buttons, or below them
# when they are placed at the top edge. Include it in the exported sprite.
inset = PROFILES['compact']['widgets'][key]['visualInsetTop']
if position['y'] >= inset:
crop_y -= inset
offset_y -= inset
else:
logical_height += inset
width, height = math.ceil(logical_width - offset_x), math.ceil(logical_height - offset_y)
if x + width > 1024:
x, y, row_height = 0, y + row_height, 0
@@ -343,7 +352,15 @@ class _Canvas:
rl.rl.rlSetBlendFactorsSeparate(0x0302, 0x0303, 1, 0x0303, 0x8006, 0x8006)
rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM_SEPARATE)
try:
self.view._ordered_widgets(content, state, only_widget=key)
if general and key == 'driver_monitor':
self._render_driver_monitor(content, state, general=True)
else:
# General edit shows a representative CEM sample even though its backdrop
# remains engaged. Driving scenes always use their unmodified state.
sample = {'cem_gauge': 'cem_curve', 'speed_limit_actions': 'slc_pending'}.get(key) if general else None
widget_state = sample_state(sample, state.customization) if sample is not None else state
self._settle(widget_state)
self.view._ordered_widgets(content, widget_state, only_widget=key)
finally:
rl.end_blend_mode()
finally:
@@ -388,21 +405,12 @@ class _Canvas:
hud._wheel_y.x = 0.0 if state.lateral_active else 25.0
self.view.torque_bar._torque_filter.x = state.torque_utilization if state.lateral_active else 0.0
self.view.torque_bar._alpha_filter.x = 1.0 if state.lateral_active else 0.0
def _label(self, scene: str) -> None:
if self.fonts is None:
return
large = self.profile == Profile.LARGE
names = {"experimental": "MANUAL EXPERIMENTAL", "cem_stop_light": "CEM TRAFFIC LIGHT",
"cem_lead": "CEM STOPPED LEAD", "cem_curve": "CEM CURVE"}
label = "SAMPLE: " + names.get(scene, scene.replace("_", " ").upper())
size = 27 if large else 14
measured = self.fonts.measure(label, FontRole.SEMI_BOLD, size)
x = (1860 if large else 476) - measured.width - (38 if large else 8)
y = 1000 if large else 213
rl.draw_rectangle_rounded(rl.Rectangle(x - 8, y - 3, measured.width + 16, measured.height + 6),
0.2, 4, rl.Color(0, 0, 0, 220))
self.fonts.draw(label, FontRole.SEMI_BOLD, size, x, y, rl.Color(255, 255, 255, 255))
if gauge := getattr(self.view, 'cem_gauge', None):
from openpilot.starpilot.ui.onroad_cem_gauge import gauge_data, _get_road_height
data = gauge_data(state)
gauge._drive_frame = state.drive_frame
gauge._lead_stopped = data is not None and data.indicator_extra == 'stopped'
gauge._road_h_filter.x = gauge._current_road_h = _get_road_height(data)
def close(self) -> None:
if self.map is not None:
@@ -434,7 +442,7 @@ class LayoutPreviewRenderer:
def __call__(self, payload: dict) -> bytes:
if type(payload) is not dict or set(payload) not in ({"document", "profile", "scene"}, {"document", "profile", "scene", "art"}):
raise ValueError("Invalid layout preview request")
if "art" in payload and payload["art"] is not True:
if "art" in payload and not (payload["art"] is True or payload["art"] == "backdrop"):
raise ValueError("Invalid widget art request")
viewport = None
if payload['profile'] == 'projection':
@@ -461,7 +469,12 @@ class LayoutPreviewRenderer:
self._last_use = time.monotonic()
if payload.get('art') is True:
positions = payload['document']['layout']['widgets'] if viewport else document['layouts'][str(profile)]
return self._canvas.render_widget_art(state, positions)
return self._canvas.render_widget_art(state, positions, general=scene == 'general')
if payload.get('art') == 'backdrop':
# Everything the car draws except the movable widgets, which the editor places over it.
# A non-empty order that names no widget keeps the ordered path and draws none of them.
order = {**state.customization.get('widgetOrder', {}), str(profile): ['backdrop']}
return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene)
return self._canvas.render(state, scene)
def expire(self) -> None:
@@ -65,5 +65,3 @@ def render_sidebar(fonts: BitmapFonts, frame: rl.Rectangle,
fonts.draw(line, FontRole.SEMI_BOLD, font_size,
rect.x + (rect.width - measured.width) / 2, y, rl.WHITE)
y += measured.height
fonts.draw("SAMPLE DATA", FontRole.SEMI_BOLD, 14, frame.x + 14, frame.y + 3,
rl.Color(255, 255, 255, 210))
@@ -23,7 +23,7 @@ RENDER_DEADLINE = 2.0
MIN_INTERVAL = 0.5
ERROR_LOG_INTERVAL = 10.0
PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n'
SCENES = frozenset(('engaged', 'aol', 'long_only', 'experimental', 'braking', 'stopped',
SCENES = frozenset(('general', 'engaged', 'aol', 'long_only', 'experimental', 'braking', 'stopped',
'cem_stop_light', 'cem_lead', 'cem_curve', 'slc_pending', 'slc_max', 'slc_equal',
'slc_pedal', 'slc_retained', 'slc_disengaged', 'slc_unavailable', 'slc_display'))
PROFILES = frozenset(('large', 'compact'))
@@ -60,7 +60,7 @@ class _Job:
def _payload(value: object) -> dict:
if not isinstance(value, dict) or type(value) is not dict or set(value) not in ({'document', 'profile', 'scene'}, {'document', 'profile', 'scene', 'art'}):
raise PreviewInvalid('Invalid preview request')
if 'art' in value and value['art'] is not True:
if 'art' in value and not (value['art'] is True or value['art'] == 'backdrop'):
raise PreviewInvalid('Invalid widget art request')
if type(value['profile']) is not str or type(value['scene']) is not str or \
value['profile'] not in RENDER_PROFILES or value['scene'] not in SCENES:
@@ -79,7 +79,7 @@ def _payload(value: object) -> dict:
except (ValueError, TypeError, OverflowError, RecursionError) as error:
raise PreviewInvalid('Invalid layout document') from error
return {'document': document, 'profile': value['profile'], 'scene': value['scene'],
**({'art': True} if value.get('art') is True else {})}
**({'art': value['art']} if 'art' in value else {})}
def validate_png(value: object) -> bytes:
+4 -4
View File
@@ -147,8 +147,6 @@ class OnroadView:
if state.camera_available and self.camera_layer is None:
raise ValueError("Camera observation requires a camera renderer")
if self.fonts.profile == Profile.LARGE:
if state.alert.size == AlertSize.FULL or not placement(state.customization, "large", "cruise_limits")["enabled"]:
self.unified_speed.collapse_sources()
self._large(state)
else:
self._compact(state)
@@ -200,7 +198,7 @@ class OnroadView:
def _preview_badge(self, state: OnroadState) -> None:
"""Keep synthetic replay visibly identified even over a full alert."""
preview = state.visual_preview
if preview is None:
if preview is None or preview.layout_sample:
return
flags = []
if preview.cem_reason is not None:
@@ -424,7 +422,9 @@ class OnroadView:
self.fonts.draw(label, FontRole.SEMI_BOLD, 29, 1390 + right_shift, 56,
curve_color)
mode = conditional_status(state)
if mode is not None and state.alert.size != AlertSize.FULL:
# The layout editor leaves out the mode caption; its CEM scenes are named in the editor itself.
sample = state.visual_preview is not None and state.visual_preview.layout_sample
if mode is not None and state.alert.size != AlertSize.FULL and not sample:
family, reason, mode_color = mode
mode_y = 101 if label is not None else 56
self.fonts.draw(f'{family} {reason}', FontRole.SEMI_BOLD, 25, 1390 + right_shift, mode_y, mode_color)
+3 -2
View File
@@ -13,7 +13,7 @@ from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
from openpilot.starpilot.ui.onroad_conditional import reason, status
from openpilot.starpilot.ui.onroad_curve import controlling, glow_color, intensity
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.onroad_customization import CEM_GAUGE_FRAME, CEM_GAUGE_INSET, placement
from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadState
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole
@@ -194,7 +194,8 @@ class AetherGauge:
return
self._lead_stopped = data.indicator_extra == 'stopped'
self._speed_mps = state.speed_mps or 0.0
rect = rl.Rectangle(saved['x'], saved['y'], 344, 300)
# Draw in the gauge's full frame; the saved box is the inset part it actually fills.
rect = rl.Rectangle(saved['x'] - CEM_GAUGE_INSET[0], saved['y'] - CEM_GAUGE_INSET[1], *CEM_GAUGE_FRAME)
self._render_unified_road(rect, rect.x + rect.width / 2, rect.y + 145 - ROAD_HALF_SIZE, data)
def _render_unified_road(self, rect, icx, icy, data, alpha=1.0):
@@ -48,6 +48,10 @@ def status(state: OnroadState) -> tuple[str, str, rl.Color] | None:
if preview is not None and preview.cem_reason in ('CURVE', 'LEAD', 'STOP LIGHT', 'SPEED'):
if state.alert.size == AlertSize.FULL:
return None
if preview.layout_sample:
# The layout editor's sample keeps the car's own reason (the gauge draws from it), not replay wording.
detail = _REASONS.get(reason(state))
return ('CEM', detail, rl.Color(112, 192, 216, 255)) if detail else None
return 'CEM', f'PREVIEW {preview.cem_reason}', rl.Color(112, 192, 216, 255)
value = (state.conditional_effective if state.longitudinal_active else None) or getattr(state, 'conditional_perception', None)
if value is None or value.choice not in (ModeChoice.CEM, ModeChoice.CCM):
+68 -31
View File
@@ -9,14 +9,18 @@ from typing import Any, NotRequired, TypedDict
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y, GAP, SOURCE_ROW_HEIGHT, sources_extent
PARAM_KEY = "OnroadCustomizations"
MAX_BYTES = 16384
# Text alignment inside the cruise and speed-limit card; left is the original look.
SPEED_ALIGNMENTS = ("left", "center", "right")
PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFFF"}
ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"}
PATH_MODES = ("default", "color", "rainbow", "acceleration")
WHEEL_SIZES = {"large": (144, 192, 240), "compact": (40, 50, 70)}
DM_SIZES = {"large": (64, 128, 192), "compact": (40, 60, 90)}
WIDGET_SIZES = {"steering_wheel": WHEEL_SIZES, "driver_monitor": DM_SIZES}
class Bounds(TypedDict):
@@ -30,6 +34,7 @@ class WidgetPlacement(TypedDict):
x: float
y: float
enabled: bool
size: NotRequired[int]
class Widget(TypedDict):
@@ -45,6 +50,7 @@ class Widget(TypedDict):
defaultAnchor: NotRequired[str]
colors: NotRequired[dict[str, str | None]]
resizable: NotRequired[dict[str, int]]
sourcesExtent: NotRequired[dict[str, float]]
class LayoutProfile(TypedDict):
@@ -72,10 +78,10 @@ PROFILES: dict[str, LayoutProfile] = {
"reservedZones": [],
"widgets": {
"current_speed": _widget("Current speed", "current_speed", 580, 300, 640, 30),
"cruise_limits": _widget("Cruise and speed limit", "cruise_limits", WIDTH, CARD_HEIGHT + 52, 88, 75),
"cruise_limits": _widget("Cruise and speed limit", "cruise_limits", WIDTH, CARD_HEIGHT, 88, 75),
"speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y),
"steering_wheel": _widget("Steering wheel", "steering_wheel", 192, 192, 1588, 75),
"driver_monitor": _widget("Driver monitoring", "driver_monitor", 192, 192, 88, 808)}},
"driver_monitor": _widget("Driver monitoring", "driver_monitor", 128, 128, 120, 840)}},
"compact": {"label": "Small UI", "width": 536, "height": 240,
"bounds": {"x": 0, "y": 0, "width": 476, "height": 240},
"protectedWidget": "speed_limit_actions",
@@ -90,6 +96,9 @@ PROFILES: dict[str, LayoutProfile] = {
# Coordinates remain anchored at the buttons, preserving saved v4 placements.
PROFILES["compact"]["widgets"]["speed_limit_actions"]["visualInsetTop"] = 32
for profile, sizes in DM_SIZES.items():
PROFILES[profile]['widgets']['driver_monitor']['default']['size'] = sizes[1]
LEGACY_WIDGETS = {"large": {"current_speed", "cruise_limits", "steering_wheel"},
"compact": {"max_speed", "speed_limit", "steering_wheel"}}
DM_WIDGETS = {profile: keys | {"driver_monitor"} for profile, keys in LEGACY_WIDGETS.items()}
@@ -143,14 +152,20 @@ PROFILES["compact"]["widgets"][STOPPED_TIMER_WIDGET] = _timer_widget
# Restore the Dom road graphic in the gap between the speed card and main speed.
# It has no background and appears only with a current selected source.
CEM_GAUGE_WIDGET = 'cem_gauge'
PROFILES['large']['widgets'][CEM_GAUGE_WIDGET] = _widget('CEM road gauge', CEM_GAUGE_WIDGET, 344, 300, 436, 75)
# The gauge draws centered in a 344 x 300 frame; its box is the part it can actually fill, at this offset inside
# that frame. Version 6 and older layouts saved the frame's corner and move by this offset when they load.
CEM_GAUGE_FRAME = (344, 300)
CEM_GAUGE_INSET = (44, 24)
CEM_GAUGE_SIZE = (256, 276)
PROFILES['large']['widgets'][CEM_GAUGE_WIDGET] = _widget('CEM road gauge', CEM_GAUGE_WIDGET, *CEM_GAUGE_SIZE,
436 + CEM_GAUGE_INSET[0], 75 + CEM_GAUGE_INSET[1])
# Older documents retain every saved placement and gain this independent widget.
OPTIONAL_WIDGETS = frozenset({MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_WIDGET})
# These editor sprites use the runtime components and their canonical assets/fonts.
RUNTIME_PREVIEW_WIDGETS = {
"large": ("current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "driver_monitor"),
"compact": ("max_speed", "steering_wheel", "conditional_mode", "following_distance", "model_confidence", "driver_monitor"),
"large": ("current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "driver_monitor", "speed_limit_actions"),
"compact": ("max_speed", "steering_wheel", "conditional_mode", "following_distance", "model_confidence", "driver_monitor", "speed_limit_actions"),
}
_FRAME_COLORS = {"cardFill": "#00000000", "cardBorder": "#00000000"}
@@ -206,6 +221,8 @@ def widget_bounds(profile, widget):
def customization_metadata():
profiles = copy.deepcopy(PROFILES)
profiles["large"]["widgets"]["driver_monitor"]["defaultAnchor"] = "driver_side"
# The editor grows the cruise box by these while speed-limit sources are shown.
profiles["large"]["widgets"]["cruise_limits"]["sourcesExtent"] = {"inline": SOURCE_ROW_HEIGHT, "card": GAP + SOURCE_ROW_HEIGHT}
profiles["large"]["inputZones"] = [{"id": "favorites_drawer", "label": "Quick Select drawer",
"x": 30, "y": 900, "width": 150, "height": 150}]
profiles["compact"]["inputZones"] = [
@@ -216,8 +233,10 @@ def customization_metadata():
for name, profile in profiles.items():
for key, widget in profile["widgets"].items():
widget["colors"] = dict(WIDGET_COLORS[name].get(key, {}))
profile["widgets"]["steering_wheel"]["resizable"] = {
"min": WHEEL_SIZES[name][0], "default": WHEEL_SIZES[name][1], "max": WHEEL_SIZES[name][2]}
for key, sizes in WIDGET_SIZES.items():
minimum, default, maximum = sizes[name]
profile["widgets"][key]["resizable"] = {"min": minimum, "default": default, "max": maximum}
profile["widgets"][key]["default"]["size"] = default
for profile, data in profiles.items():
data["widgetOrder"] = list(DEFAULT_WIDGET_ORDER[profile])
data["runtimePreviewWidgets"] = list(RUNTIME_PREVIEW_WIDGETS[profile])
@@ -229,15 +248,15 @@ def customization_metadata():
def default_document():
return {"version": 6, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
return {"version": 7, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
"widgetColors": {profile: {} for profile in PROFILES}, "layouts": {
profile: {key: {**widget["default"], **({"size": WHEEL_SIZES[profile][1]} if key == "steering_wheel" else {})}
profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})}
for key, widget in data["widgets"].items()}
for profile, data in PROFILES.items()}}
def validate_document(value, *, _legacy_geometry=False):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in (1, 2, 3, 4, 5, 6):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in (1, 2, 3, 4, 5, 6, 7):
raise ValueError("Invalid customization version")
if value['version'] < 6 and not _legacy_geometry:
result = validate_document(value, _legacy_geometry=True)
@@ -263,6 +282,14 @@ def validate_document(value, *, _legacy_geometry=False):
fields.add('speedSources')
if type(value['speedSources']) is not bool:
raise ValueError('Invalid speed source drawer preference')
if value['version'] >= 4 and 'speedSourcesCard' in value:
fields.add('speedSourcesCard')
if type(value['speedSourcesCard']) is not bool:
raise ValueError('Invalid speed source card preference')
if value['version'] >= 4 and 'speedAlign' in value:
fields.add('speedAlign')
if value['speedAlign'] not in SPEED_ALIGNMENTS:
raise ValueError('Invalid speed card alignment')
if value['version'] >= 4 and 'widgetOrder' in value:
fields.add('widgetOrder')
if 'clock24Hour' in value:
@@ -330,6 +357,14 @@ def validate_document(value, *, _legacy_geometry=False):
layouts = copy.deepcopy(layouts)
for profile, data in PROFILES.items():
if type(layouts[profile]) is dict:
monitor = layouts[profile].get('driver_monitor')
if type(monitor) is dict and 'size' not in monitor:
# Tighten the old 192px box around its 128px icon without moving the icon.
if profile == 'large':
for axis in ('x', 'y'):
if type(monitor.get(axis)) in (int, float):
monitor[axis] += 32
monitor['size'] = DM_SIZES[profile][1]
missing = set(data["widgets"]) - set(layouts[profile])
if missing and missing <= OPTIONAL_WIDGETS:
for optional in missing:
@@ -374,25 +409,33 @@ def validate_document(value, *, _legacy_geometry=False):
for key, widget in data["widgets"].items():
placement = layout[key]
bounds = widget_bounds(profile, key)
expected = {"x", "y", "enabled"} | ({"size"} if key == "steering_wheel" and value["version"] >= 4 else set())
scalable = key == 'driver_monitor' or key == 'steering_wheel' and value['version'] >= 4
expected = {"x", "y", "enabled"} | ({"size"} if scalable else set())
if type(placement) is not dict or set(placement) != expected or type(placement["enabled"]) is not bool:
raise ValueError("Invalid widget placement")
size = WHEEL_SIZES[profile][1]
if key == "steering_wheel" and value["version"] >= 4:
if scalable:
size = placement["size"]
minimum, _, maximum = WHEEL_SIZES[profile]
minimum, _, maximum = WIDGET_SIZES[key][profile]
if type(size) is not int or not minimum <= size <= maximum:
raise ValueError("Invalid steering wheel size")
raise ValueError("Invalid widget size")
# Only a gauge the saved layout actually placed moves; one filled in from defaults is already in the new box.
saved_layout = value.get("layouts", {}).get(profile) if type(value.get("layouts")) is dict else None
old_gauge = key == CEM_GAUGE_WIDGET and value["version"] < 7 and type(saved_layout) is dict and key in saved_layout
for axis, extent in (("x", "width"), ("y", "height")):
number = placement[axis]
legacy = ({'cruise_limits': (176, 483), 'speed_limit_actions': (176, 58)} if value['version'] < 5 else
{'cruise_limits': (280, 412), 'speed_limit_actions': (280, 96)})
dimensions = legacy.get(key) if _legacy_geometry and profile == 'large' else None
dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else
size if key == 'steering_wheel' else widget[extent])
size if key in WIDGET_SIZES else widget[extent])
if old_gauge:
dimension = CEM_GAUGE_FRAME[0 if axis == 'x' else 1]
if type(number) not in (int, float) or not math.isfinite(number) or not bounds[axis] <= number <= bounds[axis] + bounds[extent] - dimension:
raise ValueError("Widget outside profile bounds")
result["layouts"][profile][key] = {**placement, **({"size": size} if key == "steering_wheel" else {})}
result["layouts"][profile][key] = {**placement, **({"size": size} if key in WIDGET_SIZES else {})}
if old_gauge:
result["layouts"][profile][key].update(x=placement["x"] + CEM_GAUGE_INSET[0], y=placement["y"] + CEM_GAUGE_INSET[1])
if profile == "compact":
actions = result["layouts"][profile]["speed_limit_actions"]
area = data["widgets"]["speed_limit_actions"]
@@ -402,7 +445,7 @@ def validate_document(value, *, _legacy_geometry=False):
placement = result["layouts"][profile][key]
if key in OPTIONAL_WIDGETS and not placement["enabled"]:
continue
width, height = ((placement["size"],) * 2 if key == "steering_wheel" else
width, height = ((placement["size"],) * 2 if key in WIDGET_SIZES else
(widget["width"], widget["height"]))
if (placement["x"] < actions["x"] + area["width"] and placement["x"] + width > actions["x"] and
placement["y"] < actions["y"] + area["height"] and placement["y"] + height > actions["y"]):
@@ -418,6 +461,10 @@ def validate_document(value, *, _legacy_geometry=False):
result['widgetOrder'][profile] = validate_widget_order(order, PROFILES[profile]['widgets'])
if 'speedSources' in value:
result['speedSources'] = value['speedSources']
if 'speedSourcesCard' in value:
result['speedSourcesCard'] = value['speedSourcesCard']
if 'speedAlign' in value:
result['speedAlign'] = value['speedAlign']
if 'clock24Hour' in value:
result['clock24Hour'] = value['clock24Hour']
if 'showMaxSpeed' in value:
@@ -455,7 +502,9 @@ def placement(document, profile, widget):
def widget_size(document, profile, widget):
default = PROFILES[str(profile)]["widgets"][widget]
position = placement(document, profile, widget)
return (position.get("size", WHEEL_SIZES[str(profile)][1]),) * 2 if widget == "steering_wheel" else (default["width"], default["height"])
if widget == "cruise_limits":
return default["width"], default["height"] + sources_extent(document)
return (position.get("size", WIDGET_SIZES[widget][str(profile)][1]),) * 2 if widget in WIDGET_SIZES else (default["width"], default["height"])
def offset(document, profile, widget):
@@ -484,15 +533,3 @@ def rgba(document, key, profile=None, widget=None):
@lru_cache(maxsize=256)
def _rgba_value(color):
return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5, 7))
def set_speed_sources(params, opened):
"""Change only a valid saved document; display fallback is not write authority."""
if type(opened) is not bool:
raise ValueError('Invalid speed source drawer preference')
raw, readable = read_saved(params, PARAM_KEY, MAX_BYTES)
if not readable:
raise ValueError('Saved layout is unreadable')
document = decode_document(raw) if raw is not None else default_document()
document['speedSources'] = opened
params.put(PARAM_KEY, json.dumps(validate_document(document), separators=(',', ':')))
+20 -6
View File
@@ -6,7 +6,7 @@ import pyray as rl
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.starpilot.ui.onroad_customization import PROFILES, placement
from openpilot.starpilot.ui.onroad_customization import PROFILES, placement, widget_size
from openpilot.starpilot.ui.onroad_state import AlertSize
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
from openpilot.starpilot.ui.presentation import Profile
@@ -26,11 +26,26 @@ def valid_observation(monitor, driver):
def monitor_bounds(document, profile, is_rhd):
position = placement(document, profile, 'driver_monitor')
widget = PROFILES[str(profile)]['widgets']['driver_monitor']
width, height = widget_size(document, profile, 'driver_monitor')
x, y = position['x'], position['y']
if profile == Profile.LARGE and is_rhd and (x, y) == (widget['default']['x'], widget['default']['y']):
bounds = PROFILES['large']['bounds']
x = 2 * bounds['x'] + bounds['width'] - x - widget['width']
return rl.Rectangle(x, y, widget['width'], widget['height'])
x = 2 * bounds['x'] + bounds['width'] - x - width
return rl.Rectangle(x, y, width, height)
def draw_monitor_art(renderer, bounds, profile):
"""Scale the native icon and cone together inside the saved square."""
base = 128 if profile == Profile.LARGE else 60
renderer._rect = rl.Rectangle(bounds.x, bounds.y, base, base)
rl.rl_push_matrix()
try:
rl.rl_translatef(bounds.x, bounds.y, 0)
rl.rl_scalef(bounds.width / base, bounds.height / base, 1)
rl.rl_translatef(-bounds.x, -bounds.y, 0)
renderer._render(renderer._rect)
finally:
rl.rl_pop_matrix()
def monitor_visible(profile, state, *, fresh, onroad, top_icons):
@@ -77,8 +92,7 @@ class DriverMonitorLayer:
self.renderer.set_should_draw(visible)
is_rhd = monitor.isRHD if ready else getattr(self.renderer, '_is_rhd', False)
bounds = monitor_bounds(state.customization, self.profile, is_rhd)
size = 128 if self.profile == Profile.LARGE else 60
self.renderer.set_position(bounds.x + (bounds.width - size) / 2, bounds.y + (bounds.height - size) / 2)
self.renderer.set_position(bounds.x, bounds.y)
if ready:
self.renderer._update_state()
else:
@@ -87,4 +101,4 @@ class DriverMonitorLayer:
self.renderer._fade_filter.update(0.35 if visible else 0.0)
if visible:
draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor")
self.renderer._render(self.renderer._rect)
draw_monitor_art(self.renderer, bounds, self.profile)
+142 -73
View File
@@ -5,9 +5,9 @@ HudRenderer and ExpButton. See the adjacent UI LICENSE for retained source.
"""
from pathlib import Path
from typing import cast
import hashlib
import json
import time
import pyray as rl
@@ -16,7 +16,6 @@ from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState
from openpilot.starpilot.ui.onroad_widget_style import draw_control_card
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole
from openpilot.starpilot.ui.wheel_feedback import wheel_feedback_rgb
from openpilot.starpilot.ui.speed_source_drawer import SpeedSourceDrawer
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
@@ -35,84 +34,173 @@ class UnifiedSpeedWidget:
def __init__(self, fonts: BitmapFonts):
self.fonts = fonts
self._source_drawer = SpeedSourceDrawer()
self._source_session = None
def collapse_sources(self):
self._source_drawer.reset()
def _draw_source_contents(self, panel, state):
def _draw_sources(self, rect, state):
"""Each enabled source as a small label over its reading; the accepted source is highlighted."""
from openpilot.starpilot.ui.large_speed_geometry import PADDING
rows = [row for row in state.speed_limit.source_readings if row.enabled]
if not rows:
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 28, panel, panel.y + 30)
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 26, rect, rect.y + 22)
return
row_height = (panel.height - 32) / len(rows)
active = state.speed_limit.accepted_source
labels = {'dashboard': 'Dashboard', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
labels = {'dashboard': 'Dash', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
cell = (rect.width - 2 * PADDING) / len(rows)
for index, row in enumerate(rows):
role = FontRole.MEDIUM if row.source == active else FontRole.NORMAL
color = rl.WHITE if row.source == active else rl.Color(170, 179, 174, 255)
value = str(round(row.speed_mps * (3.6 if state.metric else 2.2369362921))) if row.kind == 'valid' and row.speed_mps is not None else '–'
label = labels[row.source]
size = 28
value_width = self.fonts.measure(value, role, 30).width
while size > 12 and self.fonts.measure(label, role, size).width > panel.width - 64 - value_width:
box = rl.Rectangle(rect.x + PADDING + index * cell, rect.y, cell, rect.height)
size = 22
while size > 12 and self.fonts.measure(labels[row.source], FontRole.NORMAL, size).width > cell - 4:
size -= 1
y = panel.y + 16 + index * row_height
label_height = self.fonts.measure(label, role, size).height
value_height = self.fonts.measure(value, role, 30).height
self.fonts.draw(label, role, size, panel.x + 32, y + (row_height - label_height) / 2, color)
self.fonts.draw(value, role, 30, panel.x + panel.width - 32 - value_width, y + (row_height - value_height) / 2, color)
_center(self.fonts, labels[row.source], FontRole.NORMAL, size, box, rect.y + 10, color)
_center(self.fonts, value, FontRole.MEDIUM if row.source == active else FontRole.NORMAL, 30, box, rect.y + 36, color)
def render(self, content: rl.Rectangle, state: OnroadState) -> None:
from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, MAX_ROW_HEIGHT, PADDING, SOURCE_ROW_HEIGHT,
card_height, source_toggle_bounds, source_panel_bounds)
from openpilot.starpilot.ui.large_speed_geometry import GAP, WIDTH, MAX_ROW_HEIGHT, LIMIT_ROW_HEIGHT, PADDING, SOURCE_ROW_HEIGHT, card_height
from openpilot.starpilot.ui.onroad_customization import placement
shown = resolve_unified_speed(state)
if shown.mode == 'hidden':
self.collapse_sources()
return
saved = placement(state.customization, 'large', 'cruise_limits')
rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, card_height(shown.mode))
height = card_height(shown.mode)
rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, height)
# Sources follow the Galaxy layout setting: a row in this card, or a small card of its own right below.
sources = (state.customization.get('speedSources') is True and shown.mode != 'max_only' and
state.speed_limit.kind == ObservationKind.VALID and bool(state.speed_limit.session_id))
separate = state.customization.get('speedSourcesCard') is True
if sources and not separate:
rect.height += SOURCE_ROW_HEIGHT
fill = rl.Color(*rgba(state.customization, 'cardFill', 'large', 'cruise_limits'))
border = rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits'))
text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits'))
muted, accent = rl.Color(172, 190, 203, 255), rl.Color(197, 163, 255, 255)
draw_control_card(rect, fill=fill, border=border, border_width=1, roundness=.09)
def label(value, x, y, size=32, color=muted, role=FontRole.MEDIUM):
align = state.customization.get('speedAlign', 'left')
def start(ink_left, ink_right):
# Align visible glyphs so the digits' side bearings do not offset them from the labels.
if align == 'right':
return rect.x + WIDTH - PADDING - ink_right
if align != 'center':
return rect.x + PADDING - ink_left
return rect.x + WIDTH / 2 - (ink_left + ink_right) / 2
def label(value, y, size=32, color=muted, role=FontRole.MEDIUM):
while size > 20 and self.fonts.measure(value, role, size).width > WIDTH - 2 * PADDING:
size -= 1
ink_top, _ = self.fonts.vertical_ink(value, role, size)
self.fonts.draw(value, role, size, x, y - ink_top, color)
self.fonts.draw(value, role, size, start(*self.fonts.horizontal_ink(value, role, size)), y - ink_top, color)
def row(title, value, top, footer, status='', active=False, footer_color=muted, footer_y=174):
left = rect.x + PADDING
label(title, left, top + 24, color=accent if active else muted)
size, unit_size = 112, 28
def row(title, value, top, footer, status='', active=False, footer_color=muted, footer_y=174, height=MAX_ROW_HEIGHT):
has_value = value != '–'
unit_width = self.fonts.measure(shown.unit, FontRole.NORMAL, unit_size).width if has_value else 0
while size > 72 and self.fonts.measure(value, FontRole.SPEED, size).width + unit_width + 14 > WIDTH - PADDING * 2:
size, unit_size = 112, 28
def fit_size(val, role, max_size, min_size=20):
sz = max_size
while sz > min_size and self.fonts.measure(val, role, sz).width > WIDTH - 2 * PADDING:
sz -= 1
return sz
title_size = fit_size(title, FontRole.MEDIUM, 32)
title_ink = self.fonts.horizontal_ink(title, FontRole.MEDIUM, title_size)
title_v = self.fonts.vertical_ink(title, FontRole.MEDIUM, title_size)
title_h = title_v[1] - title_v[0]
number_ink = self.fonts.horizontal_ink(value, FontRole.SPEED, size)
unit_ink = self.fonts.horizontal_ink(shown.unit, FontRole.NORMAL, unit_size) if has_value else (0, 0)
extra = unit_ink[1] - unit_ink[0] + 14 if has_value and align != 'center' else 0
while size > 72 and number_ink[1] - number_ink[0] + extra > WIDTH - PADDING * 2:
size -= 1
number_width = self.fonts.measure(value, FontRole.SPEED, size).width
number_top, number_bottom = self.fonts.vertical_ink(value, FontRole.SPEED, size)
number_y = top + 72 - number_top
number_ink = self.fonts.horizontal_ink(value, FontRole.SPEED, size)
number_v = self.fonts.vertical_ink(value, FontRole.SPEED, size)
number_h = number_v[1] - number_v[0]
if not has_value:
digit_top, digit_bottom = self.fonts.vertical_ink('0', FontRole.SPEED, size)
number_y += ((digit_bottom - digit_top) - (number_bottom - number_top)) / 2
self.fonts.draw(value, FontRole.SPEED, size, left, number_y, text)
digit_v = self.fonts.vertical_ink('0', FontRole.SPEED, size)
dash_offset = ((digit_v[1] - digit_v[0]) - number_h) / 2
effective_number_h = digit_v[1] - digit_v[0]
else:
dash_offset = 0
effective_number_h = number_h
if has_value:
_, unit_bottom = self.fonts.vertical_ink(shown.unit, FontRole.NORMAL, unit_size)
self.fonts.draw(shown.unit, FontRole.NORMAL, unit_size, left + number_width + 14,
top + 72 + number_bottom - number_top - unit_bottom, muted)
if status:
label(status, left, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted)
label(footer, left, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color)
unit_v = self.fonts.vertical_ink(shown.unit, FontRole.NORMAL, unit_size)
unit_h = unit_v[1] - unit_v[0]
else:
unit_v, unit_h = None, 0
status_size = fit_size(status, FontRole.NORMAL, 30) if status else 30
status_ink = self.fonts.horizontal_ink(status, FontRole.NORMAL, status_size) if status else None
status_v = self.fonts.vertical_ink(status, FontRole.NORMAL, status_size) if status else None
status_h = (cast(tuple[float, float], status_v)[1] - cast(tuple[float, float], status_v)[0]) if status else 0
footer_size = fit_size(footer, FontRole.NORMAL, 30) if footer else 30
footer_ink = self.fonts.horizontal_ink(footer, FontRole.NORMAL, footer_size) if footer else None
footer_v = self.fonts.vertical_ink(footer, FontRole.NORMAL, footer_size) if footer else None
footer_h = (cast(tuple[float, float], footer_v)[1] - cast(tuple[float, float], footer_v)[0]) if footer else 0
if align == 'center':
# Every line shares the card's horizontal center, and the entire stack is visually vertically centered.
segments = []
segments.append((
title_v[0], title_h,
lambda y: self.fonts.draw(title, FontRole.MEDIUM, title_size, start(*title_ink), y - title_v[0], accent if active else muted)
))
segments.append((
number_v[0], effective_number_h,
lambda y: self.fonts.draw(value, FontRole.SPEED, size, start(*number_ink), y - number_v[0] + dash_offset, text)
))
if has_value:
unit_v = cast(tuple[float, float], unit_v)
segments.append((
unit_v[0], unit_h,
lambda y: self.fonts.draw(shown.unit, FontRole.NORMAL, unit_size, start(*unit_ink), y - unit_v[0], muted)
))
if status:
status_ink = cast(tuple[float, float], status_ink)
status_v = cast(tuple[float, float], status_v)
segments.append((
status_v[0], status_h,
lambda y: self.fonts.draw(status, FontRole.NORMAL, status_size, start(*status_ink), y - status_v[0], accent if active else muted)
))
if footer:
footer_ink = cast(tuple[float, float], footer_ink)
footer_v = cast(tuple[float, float], footer_v)
segments.append((
footer_v[0], footer_h,
lambda y: self.fonts.draw(footer, FontRole.NORMAL, footer_size, start(*footer_ink), y - footer_v[0], footer_color)
))
num_items = len(segments)
gap = 8 if num_items >= 5 else 10 if num_items == 4 and height > 210 else 8 if num_items == 4 else 14
total_ink_height = sum(seg[1] for seg in segments)
total_content_height = total_ink_height + (num_items - 1) * gap
margin = max(PADDING / 2, (height - total_content_height) / 2)
curr_y = top + margin
for _, seg_h, draw_fn in segments:
draw_fn(curr_y)
curr_y += seg_h + gap
else:
# Left and right alignments anchor against card padding; number and unit are inline on the baseline.
label(title, top + 24, color=accent if active else muted)
number_y = top + 72 - number_v[0] + dash_offset
left = start(*number_ink)
self.fonts.draw(value, FontRole.SPEED, size, left, number_y, text)
if has_value:
unit_v = cast(tuple[float, float], unit_v)
unit_x = left + number_ink[0] - 14 - unit_ink[1] if align == 'right' else left + number_ink[1] + 14 - unit_ink[0]
unit_y = number_y + number_v[1] - unit_v[1]
self.fonts.draw(shown.unit, FontRole.NORMAL, unit_size, unit_x, unit_y, muted)
if status:
label(status, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted)
label(footer, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color)
if shown.mode != 'limit_only':
max_active = shown.active_side in ('max', 'shared') and shown.max_text != '–'
row('MAX SET', shown.max_text, rect.y, shown.max_status,
active=max_active, footer_color=accent if max_active else muted)
active=max_active, footer_color=accent if max_active else muted, height=MAX_ROW_HEIGHT)
if shown.mode != 'max_only':
top = rect.y + (MAX_ROW_HEIGHT if shown.mode == 'split' else 0)
if shown.mode == 'split':
@@ -121,33 +209,14 @@ class UnifiedSpeedWidget:
if shown.offset_text:
road_limit += f' {shown.offset_text}'
row('SLC LIMIT', shown.limit_text, top, road_limit, shown.limit_status,
active=shown.active_side in ('slc', 'shared'), footer_y=224)
session = state.speed_limit.session_id
if session != self._source_session:
self.collapse_sources()
self._source_session = session
available = state.speed_limit.kind == ObservationKind.VALID and bool(session) and shown.mode != 'max_only'
if not available:
self.collapse_sources()
return
now = state.observed_ns / 1e9 if state.observed_ns else time.monotonic()
self._source_drawer.update(state.customization.get('speedSources', False), now)
left, top, right, bottom = source_toggle_bounds(state)
if getattr(self, 'source_pressed', lambda: False)():
rl.draw_rectangle_rounded(rl.Rectangle(left, top, right - left, bottom - top), .15, 8, rl.Color(197, 163, 255, 35))
label('Hide sources' if state.customization.get('speedSources', False) else 'View sources', left + PADDING, top + 11,
size=30, role=FontRole.NORMAL)
if self._source_drawer.progress <= 0:
return
rows = sum(row.enabled for row in state.speed_limit.source_readings)
height = max(1, rows) * SOURCE_ROW_HEIGHT + PADDING
panel_bounds = source_panel_bounds(state, height, content)
if panel_bounds is None:
self.collapse_sources()
return
panel = rl.Rectangle(*panel_bounds)
self._source_drawer.draw_panel(panel, content, fill, border,
lambda panel: self._draw_source_contents(panel, state))
active=shown.active_side in ('slc', 'shared'), footer_y=224, height=LIMIT_ROW_HEIGHT)
if sources:
row = rl.Rectangle(rect.x, rect.y + height + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT)
if separate:
draw_control_card(row, fill=fill, border=border, border_width=1, roundness=.2)
else:
rl.draw_line_ex(rl.Vector2(rect.x + 1, row.y), rl.Vector2(rect.x + WIDTH - 1, row.y), 1, border)
self._draw_sources(row, state)
class CurrentSpeedHud:
+5 -28
View File
@@ -258,9 +258,12 @@ def _default_slc_controls(profile: Profile, state: OnroadState) -> tuple[SlcCont
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y, ACTION_HEADER_HEIGHT
target = observation.pending_adjusted_limit_mps
label = f'Apply {round(target * (3.6 if state.metric else 2.2369362921))}' if target is not None and target > 0 else 'Apply'
# Name the speed each choice leaves you at: the new target, or the limit already in use.
current = observation.accepted_adjusted_limit_mps
keep = f'Keep {round(current * (3.6 if state.metric else 2.2369362921))}' if current is not None and current > 0 else 'Keep'
return (SlcControl(request(SlcActionKind.ACCEPT), label,
(ACTION_X, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH // 2 - 6, ACTION_Y + ACTION_HEIGHT)),
SlcControl(request(SlcActionKind.REJECT), 'Keep',
SlcControl(request(SlcActionKind.REJECT), keep,
(ACTION_X + WIDTH // 2 + 6, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH, ACTION_Y + ACTION_HEIGHT)))
return (SlcControl(request(SlcActionKind.ACCEPT), "ACCEPT", (174, 180, 310, 234)),
SlcControl(request(SlcActionKind.REJECT), "REJECT", (320, 180, 456, 234)))
@@ -323,20 +326,6 @@ class OnroadInput:
self.profile = profile
self._press: tuple[float, float, bool, str] | None = None
self._slc_press: tuple[float, float, SlcUiRequest] | None = None
self._source_press = None
def _source_target(self, x, y, state):
if (self.profile != Profile.LARGE or state.alert.size != AlertSize.NONE or
state.speed_limit.kind != ObservationKind.VALID or not state.speed_limit.session_id or
not placement(state.customization, 'large', 'cruise_limits')['enabled']):
return False
from openpilot.starpilot.ui.large_speed_geometry import source_toggle_bounds
left, top, right, bottom = source_toggle_bounds(state)
return left <= x <= right and top <= y <= bottom
@property
def source_pressed(self):
return self._source_press is not None
@property
def pressed_action(self):
@@ -344,7 +333,7 @@ class OnroadInput:
@property
def claimed(self) -> bool:
return self._press is not None or self._slc_press is not None or self._source_press is not None
return self._press is not None or self._slc_press is not None
def press(self, x: float, y: float, state: OnroadState) -> None:
self.cancel()
@@ -354,9 +343,6 @@ class OnroadInput:
if left <= x <= right and top <= y <= bottom:
self._slc_press = (x, y, control.request)
return
if self._source_target(x, y, state):
self._source_press = (x, y, state.speed_limit.session_id, state.customization.get('speedSources', False))
return
# Only the large-profile wheel is an experimental-mode touch target.
if self.profile != Profile.LARGE:
self.cancel()
@@ -365,12 +351,6 @@ class OnroadInput:
self._press = (x, y, state.experimental_enabled, state.experimental_action_token) if inside and state.experimental_available else None
def move(self, x: float, y: float, state: OnroadState) -> None:
if self._source_press:
px, py, session, opened = self._source_press
if (abs(x - px) > 5 or abs(y - py) > 5 or session != state.speed_limit.session_id or
opened != state.customization.get('speedSources', False) or not self._source_target(x, y, state)):
self.cancel()
return
if self._slc_press:
px, py, request = self._slc_press
current = next((item for item in slc_controls(self.profile, state) if item.request == request), None)
@@ -386,8 +366,6 @@ class OnroadInput:
def release(self, x: float, y: float, state: OnroadState) -> None:
self.move(x, y, state)
if self._source_press:
self.emit(OnroadRequest('set_speed_sources', not self._source_press[3]))
if self._slc_press:
self.emit(self._slc_press[2])
if self._press:
@@ -397,7 +375,6 @@ class OnroadInput:
def cancel(self) -> None:
self._press = None
self._slc_press = None
self._source_press = None
class SlcStateMessage(Protocol):
+55
View File
@@ -6,6 +6,8 @@ from pathlib import Path
import hashlib
import json
import numpy as np
from PIL import Image
import pyray as rl
@@ -104,9 +106,12 @@ class BitmapFonts:
raise RuntimeError("Bitmap fonts require an initialized graphics context")
self._headless_context = headless_context
self.profile = Profile(profile)
self._directory = directory
self._fonts: dict[str, rl.Font] = {}
self._measurements: dict[tuple, TextSize] = {}
self._vertical_ink: dict[tuple, tuple[float, float]] = {}
self._horizontal_ink: dict[tuple, tuple[float, float]] = {}
self._glyph_ink: dict[str, dict[int, tuple[int, int] | None]] = {}
default_texture_id = rl.get_font_default().texture.id
try:
for filename in dict.fromkeys(font_filename(self.profile, role) for role in FontRole):
@@ -169,6 +174,54 @@ class BitmapFonts:
self._vertical_ink[key] = (top * scale, bottom * scale)
return self._vertical_ink[key]
def horizontal_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]:
"""Visible glyph left/right from the draw x, without the font's side bearings."""
key = (text, role, size)
if key not in self._horizontal_ink:
font = self.font(role)
width_scale, weight = self._optical_style(role)
scale = size * self.profile.font_scale / font.baseSize
left = right = None
x = 0.
columns = self._glyph_columns(role)
for char in text:
index = rl.get_glyph_index(font, ord(char))
glyph, rec = font.glyphs[index], font.recs[index]
# Fixed-width fonts (DINish-Speed) pad each glyph cell, so use the atlas's inked columns.
ink = None if char.isspace() else columns.get(index, (0, rec.width - 1) if rec.width > 0 else None)
if ink is not None:
left = x + glyph.offsetX + ink[0] if left is None else min(left, x + glyph.offsetX + ink[0])
right = x + glyph.offsetX + ink[1] + 1 if right is None else max(right, x + glyph.offsetX + ink[1] + 1)
x += glyph.advanceX or rec.width
if left is None:
left = right = 0.
if len(self._horizontal_ink) >= 1024:
self._horizontal_ink.clear()
# Match draw(): the optical width scale applies to the whole run and the weight pass shifts right.
self._horizontal_ink[key] = (left * scale * width_scale, right * scale * width_scale + (weight if text else 0))
return self._horizontal_ink[key]
def _glyph_columns(self, role: FontRole) -> dict[int, tuple[int, int] | None]:
"""Measure the reviewed PNG, without GPU readback during a widget render.
Texture readback depends on the graphics backend and can disturb the active
framebuffer used for Galaxy's widget previews. The source atlas has the same
pixels and works independently of the current render target.
"""
filename = font_filename(self.profile, role)
if filename not in self._glyph_ink:
font = self.font(role)
with Image.open(font_path(self._directory, filename).with_suffix('.png')) as atlas:
alpha = np.asarray(atlas.convert('RGBA').getchannel('A'))
columns = {}
for index in range(font.glyphCount):
rec = font.recs[index]
cell = alpha[int(rec.y):int(rec.y + rec.height), int(rec.x):int(rec.x + rec.width)]
inked = np.flatnonzero(cell.max(0) > 40) if cell.size else []
columns[index] = (int(inked[0]), int(inked[-1])) if len(inked) else None
self._glyph_ink[filename] = columns
return self._glyph_ink[filename]
def draw(self, text: str, role: FontRole, size: float, x: float, y: float, color: rl.Color = rl.WHITE,
*, spacing: float = 0, fallback: bool = False) -> None:
draw = getattr(rl, "_orig_draw_text_ex", rl.draw_text_ex)
@@ -193,6 +246,8 @@ class BitmapFonts:
rl.unload_font(font)
self._fonts.clear()
self._vertical_ink.clear()
self._horizontal_ink.clear()
self._glyph_ink.clear()
self._measurements.clear()
def __enter__(self):
+12 -19
View File
@@ -13,7 +13,7 @@ import math
from pathlib import Path
import time
import threading
from typing import Any
from typing import Any, cast
from collections.abc import Callable
import pyray as rl
@@ -232,7 +232,6 @@ class StarShellSession:
native_panels=NATIVE_SETTINGS_PANELS if profile == Profile.LARGE and settings_layer is not None else ())
if profile == Profile.LARGE:
self.view.onroad.pressed_action = lambda: self.input.onroad.pressed_action
self.view.onroad.unified_speed.source_pressed = lambda: self.input.onroad.source_pressed
self.selected = Destination.STAR
self.sidebar_expanded = True
self._onroad_width = 1860.0
@@ -260,6 +259,8 @@ class StarShellSession:
self._visual_preview_flags = parse_flags(raw_preview)
self._visual_preview_start_ns = time.monotonic_ns()
self._native_favorite_actions: Callable[[], dict] = dict
# The native layout's bookmark publisher, for car-screen taps that are not taps on this screen.
self._publish_bookmark: Callable[[], None] | None = None
self._wheel_sock = messaging.sub_sock("slcCruiseEvent", conflate=False)
self._aol_save = None
self._aol_save_closed = False
@@ -463,13 +464,15 @@ class StarShellSession:
receiver = getattr(self, '_projection_bookmarks', None)
if receiver is None:
return
authorized = bool(ui_state.started and self._favorite_authority() and
current_message(ui_state.sm, 'carState', now_ns, after_frame=ui_state.started_frame) is not None)
# A car-screen tap bookmarks whenever onroad with live car state, like the wheel button: this
# screen's alerts and page do not gate it. A render frame gap past carState's 20 ms control
# limit must not drop it either (drained requests are not retried).
publish = getattr(self, '_publish_bookmark', None)
authorized = bool(publish is not None and ui_state.started and getattr(ui_state, "replay_clock", None) is None and
display_message(ui_state.sm, 'carState', now_ns, after_frame=ui_state.started_frame) is not None)
stamp = int(ui_state.sm.logMonoTime['carState']) if authorized else 0
for _ in range(receiver.drain(now_ns, stamp, authorized=authorized)):
action = self._native_favorite_actions().get(BOOKMARK)
if action is not None and action.available and action.invoke is not None:
action.invoke()
cast('Callable[[], None]', publish)()
def _poll_wheel(self, now_ns):
if getattr(ui_state, "replay_clock", None) is not None:
@@ -1286,7 +1289,6 @@ class StarShellSession:
self._snapshot_cache = None
snapshot = self.snapshot(mode)
self._mode = mode
self._poll_projection_bookmark(time.monotonic_ns())
if mode == ShellMode.ONROAD and self.slc_actions is not None:
observation = snapshot.onroad.speed_limit
snapshot = replace(snapshot, onroad=replace(snapshot.onroad, speed_limit=replace(
@@ -1358,17 +1360,6 @@ class StarShellSession:
self._request_emitted = True
previous_destination = self.selected
action = request.action
if request.source == "onroad" and getattr(action, "kind", None) == "set_speed_sources":
from openpilot.starpilot.ui.onroad_customization import set_speed_sources
if self._mode == ShellMode.ONROAD and type(action.value) is bool:
try:
set_speed_sources(self.adapter.ui_state.params, action.value)
except (AttributeError, OSError, TypeError, ValueError, UnicodeError, RecursionError):
self._unavailable('speed source drawer preference; saved layout needs review')
else:
self.adapter.invalidate_appearance()
self._snapshot_cache = None
return
if request.source == "onroad" and getattr(action, "kind", None) == "set_experimental":
binding = self._native_favorite_actions().get(EXPERIMENTAL)
if not (binding is not None and binding.available and binding.invoke is not None and
@@ -1781,6 +1772,7 @@ class StarMainLayout(MainLayout):
on_destination_change=self._network_destination)
self.star.set_request_owner(self._deliver_settings_request)
self.star._native_favorite_actions = self._native_favorites
self.star._publish_bookmark = self._on_bookmark_clicked
def _native_favorites(self):
from openpilot.selfdrive.ui.layouts.settings.settings import PanelType
@@ -2012,6 +2004,7 @@ class StarMiciMainLayout(MiciMainLayout):
self._scroller.set_scrolling_enabled(lambda: not self._native_onroad.is_swiping_left())
self._on_body_changed()
self.star._native_favorite_actions = self._native_favorites
self.star._publish_bookmark = self._on_bookmark_clicked
def _render(self, _) -> None:
rect = _
+8 -3
View File
@@ -41,6 +41,7 @@ from openpilot.starpilot.ui.home_state import HomeMode, HomeState
from openpilot.starpilot.ui.onroad_state import conditional_lead_from_message
from openpilot.starpilot.ui.onroad_state import (AlertSize, BorderSignals, ObservationKind, OnroadAlert,
OnroadState, SpeedLimitObservation, is_personality_notice, speed_limit_from_message)
from openpilot.starpilot.ui.unified_speed_presentation import displayed_adjusted_limit_mps, displayed_limit_mps, positive
from openpilot.starpilot.ui.onroad_stopped_timer import StoppedTimer
from openpilot.starpilot.ui.onroad_lane_alerts import lateral_lane_alert
from openpilot.starpilot.ui.onroad_camera import ReverseDriverCamera
@@ -572,9 +573,13 @@ class RuntimeSnapshotAdapter:
str(getattr(slc, 'observationKind', '')) == 'unknown' and str(getattr(slc, 'source', '')) == 'none' and
0 <= now_ns - stamp <= 150_000_000 and not bool(getattr(slc, 'hasPending', False)) and
not getattr(slc, 'decisionId', 0)):
observation = SpeedLimitObservation(kind=ObservationKind.VALID, source=previous.source,
speed_limit_mps=previous.speed_limit_mps,
offset_mps=previous.offset_mps, status='display_hold')
# Retain the displayed sign and adjusted number, without retaining a
# control target, acceptance or action identity during the gap.
source = previous.accepted_source if positive(previous.accepted_speed_limit_mps) else previous.source
observation = SpeedLimitObservation(kind=ObservationKind.VALID, source=source,
speed_limit_mps=displayed_limit_mps(previous),
offset_mps=previous.offset_mps, status='display_hold',
presentation_adjusted_limit_mps=displayed_adjusted_limit_mps(previous))
else:
self._slc_sign_cache = None
else:
-2
View File
@@ -85,8 +85,6 @@ class ShellView:
acquired.pop_all()
def render(self, snapshot: ShellSnapshot, drag_x: float = 0.0) -> None:
if snapshot.mode != ShellMode.ONROAD and self.profile == Profile.LARGE:
self.onroad.unified_speed.collapse_sources()
if snapshot.mode == ShellMode.HOME:
self.home.render(snapshot.home)
elif snapshot.mode == ShellMode.ONROAD:
@@ -1,17 +0,0 @@
"""A vertically revealed, display-only source panel."""
import pyray as rl
from openpilot.starpilot.ui import clip
from openpilot.starpilot.ui.speed_source_drawer_model import DrawerMotion
class SpeedSourceDrawer(DrawerMotion):
def draw_panel(self, panel, parent, fill, border, draw_contents):
if self.progress <= 0:
return None
visible = rl.Rectangle(panel.x, panel.y, panel.width, panel.height * self.progress)
with clip.clipped(visible, parent):
rl.draw_rectangle_rounded(panel, .14, 12, fill)
rl.draw_rectangle_rounded_lines_ex(panel, .14, 12, 1, border)
draw_contents(panel)

Some files were not shown because too many files have changed in this diff Show More