mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 829aed275e |
@@ -56,18 +56,6 @@ 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:
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"""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()
|
||||
@@ -268,43 +268,13 @@ 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:
|
||||
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
|
||||
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: list[str] | None = None) -> bool:
|
||||
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
|
||||
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: list[str] | None = None) -> bool:
|
||||
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
|
||||
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: list[str] | None = None) -> bool:
|
||||
return self.all_alive(service_list) and self.all_freq_ok(service_list) and self.all_valid(service_list)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""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()
|
||||
@@ -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
|
||||
from openpilot.starpilot.car.hyundai.aol import aol_safety_config as hyundai_aol_safety_config, qualified_ioniq6
|
||||
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,18 +773,25 @@ 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 not qualified_honda_stock_aol(self.CP, marked_only=True) or
|
||||
observation is None or observation[0] is not CS):
|
||||
if not CS.brakePressed 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]
|
||||
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))
|
||||
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
|
||||
_, 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,12 +229,11 @@ 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,
|
||||
poll_camera: bool = True) -> None:
|
||||
lateral_active: bool = False, road_style: dict | None = None, paint: 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 poll_camera and (camera_view == CAMERA_VIEW_DRIVER or reverse_driver):
|
||||
if 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:
|
||||
@@ -242,7 +241,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 poll_camera and self._switching and driver_transition:
|
||||
if 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):
|
||||
@@ -255,7 +254,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, poll_camera=poll_camera)
|
||||
gui_app.measure_frame_phase("camera", CameraView._render, self, rect, paint=paint)
|
||||
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)
|
||||
@@ -293,9 +292,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, poll_camera: bool = True):
|
||||
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True):
|
||||
if not paint:
|
||||
super()._render(rect, paint=False, poll_camera=poll_camera)
|
||||
super()._render(rect, paint=False)
|
||||
return
|
||||
# Draw text if not onroad
|
||||
if not ui_state.started:
|
||||
@@ -326,7 +325,7 @@ class AugmentedRoadView(CameraView):
|
||||
)
|
||||
|
||||
# Render the base camera view
|
||||
super()._render(self._content_rect, poll_camera=poll_camera)
|
||||
super()._render(self._content_rect)
|
||||
|
||||
# Draw all UI overlays
|
||||
self._model_renderer.render(self._content_rect)
|
||||
|
||||
@@ -227,24 +227,25 @@ class CameraView(Widget):
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
def _poll_camera(self) -> bool:
|
||||
def _render(self, rect: rl.Rectangle, /, *, paint: bool = True):
|
||||
if self._switching:
|
||||
self._handle_switch()
|
||||
|
||||
if not self._ensure_connection():
|
||||
return False
|
||||
if paint:
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
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 not self.frame:
|
||||
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,7 +221,18 @@ class ModelRenderer(Widget):
|
||||
|
||||
@staticmethod
|
||||
def _format_lead_info(lead, mode: int, metric: bool | None) -> str:
|
||||
return format_lead_info(lead, mode, metric)
|
||||
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'}"
|
||||
|
||||
@staticmethod
|
||||
def _valid_lead_numbers(lead) -> bool:
|
||||
|
||||
@@ -48,8 +48,7 @@ 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,
|
||||
lead_display: tuple[bool, int, bool | None] | None = None, poll_camera: bool = True) -> None:
|
||||
def render_camera_model_layer(self, rect: rl.Rectangle, *, road_style: dict | None = None) -> 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
|
||||
@@ -60,11 +59,10 @@ 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
|
||||
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("camera", CameraView._render, self, rect)
|
||||
gui_app.measure_frame_phase("model", self.model_renderer.render, rect)
|
||||
|
||||
def _render(self, rect, *, poll_camera: bool = True):
|
||||
def _render(self, rect):
|
||||
# Only render when system is started to avoid invalid data access
|
||||
if not ui_state.started:
|
||||
return
|
||||
@@ -92,7 +90,7 @@ class AugmentedRoadView(CameraView):
|
||||
)
|
||||
|
||||
# Render the base camera view
|
||||
super()._render(self._content_rect, poll_camera=poll_camera)
|
||||
super()._render(self._content_rect)
|
||||
|
||||
# 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, *, poll_camera: bool = True):
|
||||
super()._render(rect, poll_camera=poll_camera)
|
||||
def _render(self, rect):
|
||||
super()._render(rect)
|
||||
|
||||
if not self.frame:
|
||||
gui_label(
|
||||
|
||||
@@ -213,24 +213,24 @@ class CameraView(Widget):
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
def _poll_camera(self) -> bool:
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._switching:
|
||||
self._handle_switch()
|
||||
|
||||
if not self._ensure_connection():
|
||||
return False
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
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:
|
||||
if not self.frame:
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ 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 FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
@@ -61,8 +59,6 @@ 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
|
||||
@@ -127,9 +123,7 @@ 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
|
||||
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
|
||||
render_lead_indicator = self._longitudinal_control and radar_state is not None
|
||||
|
||||
# Update model data when needed
|
||||
model_updated = sm.updated['modelV2']
|
||||
@@ -152,10 +146,6 @@ 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"""
|
||||
@@ -377,17 +367,6 @@ 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"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.hardware import COMMA_HARDWARE, HARDWARE
|
||||
@@ -16,12 +15,8 @@ from openpilot.starpilot.ui.startup import select_ui
|
||||
BIG_UI = gui_app.big_ui()
|
||||
|
||||
|
||||
def update_frame(preview=None, controllers=None, star=None):
|
||||
def update_frame(preview=None, controllers=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:
|
||||
@@ -60,9 +55,8 @@ 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, star)):
|
||||
for should_render, frame_time, cpu_time in gui_app.render(before_frame=lambda: update_frame(preview, controllers)):
|
||||
if should_render:
|
||||
if camera_core_ui and not main_thread_configured:
|
||||
# Share the camera core with normal scheduling, without FIFO display
|
||||
|
||||
@@ -487,12 +487,17 @@ 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."""
|
||||
"""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."""
|
||||
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 not latched or self.failed or self.paused or self.pending_event or
|
||||
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
|
||||
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
|
||||
|
||||
@@ -76,7 +76,7 @@ def _bounded_id(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _payload(raw_value, schema, kind: int, materialize, versions=(WIRE_VERSION,)):
|
||||
def _payload(raw_value, schema, kind: int, 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, materialize, 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 materialize(decoded)
|
||||
return decoded.to_dict()
|
||||
except (ValueError, OverflowError, RuntimeError, UnicodeDecodeError, capnp.KjException):
|
||||
return None
|
||||
|
||||
@@ -112,52 +112,49 @@ def _memoized(decode):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _read_safety(value) -> SafetyState | None:
|
||||
try:
|
||||
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.pandaSerial)),
|
||||
_bounded_id(str(value.axisSessionId)), source_ns, inventory)
|
||||
if result.validUntilMonoTime < result.observedMonoTime:
|
||||
return None
|
||||
return result
|
||||
except (KeyError, ValueError, OverflowError, RuntimeError, capnp.KjException):
|
||||
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))
|
||||
value = _payload(raw, custom.AolAxisState.SafetyWire, SAFETY_KIND, (WIRE_VERSION, SAFETY_INVENTORY_VERSION))
|
||||
if value is None:
|
||||
return 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 <= 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)
|
||||
if result.validUntilMonoTime < result.observedMonoTime:
|
||||
return None
|
||||
return result
|
||||
except (KeyError, ValueError, OverflowError, RuntimeError, capnp.KjException):
|
||||
return None
|
||||
|
||||
|
||||
@_memoized
|
||||
def decode_intent(raw: bytes) -> IntentState | None:
|
||||
return _payload(raw, custom.AolAxisState.IntentWire, INTENT_KIND, _read_intent)
|
||||
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
|
||||
|
||||
|
||||
def encode_intent(value: IntentState) -> bytes:
|
||||
|
||||
@@ -272,6 +272,10 @@ 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)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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
|
||||
@@ -5,14 +5,11 @@ 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 FILTER_THRESHOLD, FILTER_TIME_S, LeadDetector
|
||||
from openpilot.starpilot.lead_tracking import 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:
|
||||
@@ -70,14 +67,12 @@ 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
|
||||
|
||||
@@ -106,22 +101,22 @@ class CurveHost:
|
||||
required = ('modelV2', 'carState', 'carControl', 'controlsState', 'selfdriveState', 'radarState')
|
||||
try:
|
||||
camera_eof = sm['modelV2'].timestampEof
|
||||
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
|
||||
camera_current = (type(camera_eof) is int and 0 < camera_eof <= pair[1] and
|
||||
pair[1] - camera_eof <= 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.
|
||||
# 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:
|
||||
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)):
|
||||
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)
|
||||
# A skipped stale frame needs no reset: the detector resets itself on longer gaps.
|
||||
if frame.inputs_fresh and frame.profile is not None:
|
||||
if not frame.inputs_fresh or frame.profile is None:
|
||||
self._reset_leads()
|
||||
else:
|
||||
try:
|
||||
# Tracking precedes headway reset: the first inactive tick retains the prior headway,
|
||||
# then uses zero. Active control requires this cycle's MPC headway.
|
||||
@@ -136,16 +131,9 @@ 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)
|
||||
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)
|
||||
frame = replace(frame, tracking_lead=observed.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)
|
||||
|
||||
@@ -76,7 +76,6 @@ 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
|
||||
@@ -95,7 +94,6 @@ 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
|
||||
@@ -112,7 +110,6 @@ 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
|
||||
@@ -130,7 +127,6 @@ 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
|
||||
@@ -153,8 +149,6 @@ 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
|
||||
@@ -236,32 +230,24 @@ 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
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -50,8 +50,7 @@ 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 and planner.last_curve_ceiling_applied),
|
||||
'applied': bool(planner.last_curve_ceiling_applied), 'controlling': bool(runtime.was_controlling),
|
||||
'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,50 +101,6 @@ 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
|
||||
@@ -222,30 +178,6 @@ 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,9 +1,7 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -86,58 +84,6 @@ 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,10 +66,9 @@ 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', 'app_version') if key in ident},
|
||||
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error') 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': [
|
||||
@@ -134,8 +133,7 @@ class AndroidAutoSetup:
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
self._require_session(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))
|
||||
self.job.start(path=path, enabled=lambda: self.session_valid(session))
|
||||
started = True
|
||||
return self.status(session)
|
||||
finally:
|
||||
|
||||
@@ -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 not (payload['art'] is True or payload['art'] == 'backdrop')) or
|
||||
('art' in payload and payload['art'] is not True) 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': payload['art']} if 'art' in payload else {})},
|
||||
**({'art': True} if payload.get('art') is True else {})},
|
||||
layout_preview_socket)
|
||||
if self.settings_session() != identity:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
|
||||
@@ -11,9 +11,8 @@ 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, comma_display, decode_layout, default_layout, layout_metadata, validate_layout,
|
||||
DOCUMENT_KEY, MAX_BYTES, ProjectionLayoutSource, 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
|
||||
|
||||
|
||||
@@ -65,9 +64,6 @@ 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:
|
||||
|
||||
@@ -57,7 +57,6 @@ 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,
|
||||
@@ -673,7 +672,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', 'instance': INSTANCE})
|
||||
self.json(200, {'schemaVersion': 1, 'monitor': 'local'})
|
||||
elif path in ('/api/local-access', '/api/tmux/live', '/api/troubleshoot'):
|
||||
if not self.require_session():
|
||||
return
|
||||
@@ -814,7 +813,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, 'instance': INSTANCE})
|
||||
self.json(200, result)
|
||||
else:
|
||||
if self.require_session():
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
@@ -2031,7 +2030,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 Android Auto first'})
|
||||
self.json(403, {'error': 'Enable experimental 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, app_version=None):
|
||||
def start(self, *, path, enabled):
|
||||
self.calls.append((path.read_bytes(), enabled))
|
||||
self.running = True
|
||||
|
||||
|
||||
@@ -72,9 +72,8 @@ 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, app_version=None):
|
||||
def start(self, *, path, enabled):
|
||||
self.started.append((path, path.read_bytes(), enabled))
|
||||
self.app_version = app_version
|
||||
self.running = True
|
||||
|
||||
|
||||
@@ -94,8 +93,6 @@ 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
|
||||
@@ -118,17 +115,6 @@ 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,23 +73,6 @@ 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)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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,20 +305,14 @@ 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, Update / Install newer version opens the installer itself; no removal first.
|
||||
// Once installed, Manage package shows status and removal only; setup needs removal first.
|
||||
const managed = page({ ...setup, identity: { ...setup.identity, installed: true } })
|
||||
managed.openInstall()
|
||||
assert.equal(managed.installState, 'choose')
|
||||
managed.installOpen = true
|
||||
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'), '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/)
|
||||
assert(!managedTags.includes('input'), 'manage view offers no setup inputs')
|
||||
assert(managedTags.includes('dl'), 'manage view shows status')
|
||||
for (const [field, reason] of [['installReady', /display|encoder/],
|
||||
['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) {
|
||||
const blocked = page({ ...setup, [field]: false })
|
||||
@@ -409,21 +403,7 @@ connectingPage.runtime.state = 'streaming'
|
||||
assert.equal(connectingPage.projecting, true)
|
||||
|
||||
assert.match(AndroidAutoPage.template, /Remove Android Auto support/)
|
||||
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"/)
|
||||
assert.match(AndroidAutoPage.template, /Manage package/)
|
||||
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
try {
|
||||
for (const [browser, mobile] of [
|
||||
|
||||
@@ -113,15 +113,6 @@ 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, $emit(event) { if (event === "close") closed++ } }
|
||||
leave: OnroadLayoutPage.methods.leave, hideDevicePreview() {}, $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,10 +48,9 @@ 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 tracked = []
|
||||
const initialize = new Function("loadCatalog", "state", "authState", "auth", "document", "deviceRestart", `${initializer}; return initialize`)(
|
||||
const initialize = new Function("loadCatalog", "state", "authState", "auth", "document", `${initializer}; return initialize`)(
|
||||
async () => { if (fail) throw new Error("offline"); return loaded }, state, authState,
|
||||
{ async check() { checks++; authState.status = "authenticated" } }, { hidden: false }, { track: instance => tracked.push(instance) },
|
||||
{ async check() { checks++; authState.status = "authenticated" } }, { hidden: false },
|
||||
)
|
||||
await initialize()
|
||||
assert.match(state.error, /could not connect/)
|
||||
@@ -62,11 +61,10 @@ 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", "deviceRestart", `${initializer}; return initialize`)(
|
||||
() => new Promise(resolve => queued.push(resolve)), state, authState, { async check() {} }, { hidden: false }, { track() {} },
|
||||
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 old = raced(), current = raced()
|
||||
queued[0]({ tools: [], mode: "sample" })
|
||||
@@ -94,11 +92,10 @@ const sessionAuth = new LocalAuth({
|
||||
},
|
||||
})
|
||||
const unmountBody = app.match(/ beforeUnmount\(\) \{([\s\S]*?)\n \},/)[1]
|
||||
const harness = new Function("loadCatalog", "state", "authState", "auth", "document", "setTimeout", "clearTimeout", "deviceRestart", "window",
|
||||
const harness = new Function("loadCatalog", "state", "authState", "auth", "document", "setTimeout", "clearTimeout",
|
||||
`${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,12 +7,11 @@ 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}, $emit(event,target){selected = [event,target]}}
|
||||
const vm = {busy:false, dirty:false, state:{drag:null}, hideDevicePreview(){}, $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('data-preview-source="device-backdrop"'))
|
||||
assert.ok(!OnroadLayoutPage.template.includes('devicePreviewOpen'))
|
||||
assert.ok(OnroadLayoutPage.template.includes('v-if="state.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(){}})
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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), ["general", "engaged", "aol", "long_only", "experimental", "braking", "stopped",
|
||||
assert.deepEqual(PREVIEW_SCENES.map(({ id }) => id), ["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 ["general", "cem_stop_light", "cem_lead", "cem_curve", "slc_pending", "slc_max", "slc_equal",
|
||||
for (const scene of ["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,22 +99,6 @@ 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,7 +94,6 @@ 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 ["sceneHidden", "previewHidden", "togglePreviewVisibility"])
|
||||
for (const name of ["previewHidden", "togglePreviewVisibility"])
|
||||
vm[name] = OnroadLayoutPage.methods[name].bind(vm)
|
||||
return vm
|
||||
}
|
||||
@@ -13,33 +13,20 @@ 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.state.scene, "general")
|
||||
assert.equal(vm.previewHidden("stopped_timer"), false)
|
||||
assert.equal(vm.previewHidden("stopped_timer"), true)
|
||||
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"), true, "General edit eye preferences survive reopening")
|
||||
assert.equal(reopened.previewHidden("stopped_timer"), false, "explicitly showing the timer survives 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]
|
||||
@@ -47,18 +34,11 @@ 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"), false)
|
||||
assert.equal(vm.previewHidden("stopped_timer"), true)
|
||||
assert.equal(vm.previewHidden("current_speed"), false)
|
||||
}
|
||||
globalThis.localStorage = {
|
||||
@@ -72,4 +52,4 @@ try {
|
||||
if (originalStorage === undefined) delete globalThis.localStorage
|
||||
else globalThis.localStorage = originalStorage
|
||||
}
|
||||
console.log("Preview visibility: General edit persistence, scene/target/profile isolation, timer eligibility, required exit and unavailable storage passed")
|
||||
console.log("Preview visibility: persistence, target/profile isolation, timer default, required exit and unavailable storage passed")
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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: 7, roadColors: { large: {}, compact: {} }, widgetColors: { large: {}, compact: {} }, palette: Object.fromEntries(metadata.paletteFields.map((field) => [field.id, field.default])),
|
||||
const document = { version: 6, 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 = [], backdropCalls = [], captures = new Set()
|
||||
const emitted = [], calls = [], previewCalls = [], 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,199 +135,16 @@ 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, backdropCalls, captures, publish }
|
||||
return { vm, event, emitted, calls, previewCalls, 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
|
||||
@@ -414,10 +231,8 @@ 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, 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])
|
||||
const { vm, event, emitted, calls, previewCalls, captures } = editor()
|
||||
assert.equal(vm.state.devicePreviewOpen, false)
|
||||
assert.equal(vm.dirty, false)
|
||||
vm.changePosition("current_speed", 700, 200)
|
||||
vm.selectProfile("compact")
|
||||
@@ -481,80 +296,41 @@ assert.equal(vm.selectedColors.text, "#FFFFFFFF")
|
||||
vm.colorRgb("text", { target: { value: "#abcdef" } })
|
||||
vm.colorAlpha("text", { target: { value: "128" } })
|
||||
assert.equal(vm.selectedColors.text, "#ABCDEF80")
|
||||
// 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"
|
||||
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])
|
||||
vm.selectProfile("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")
|
||||
assert.equal(previewCalls.at(-1)[2], "compact")
|
||||
vm.state.scene = "braking"
|
||||
assert.equal(backdropCalls.at(-1)[3], "braking")
|
||||
assert.equal(previewCalls.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(backdropCalls.length, backdropCount + 5) // Color edits change the real screen too.
|
||||
assert.equal(previewCalls.length, previewCount)
|
||||
vm.color("text", "#ABCDEF80")
|
||||
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
|
||||
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()
|
||||
vm.requestLeave("reload")
|
||||
assert.equal(vm.state.discard, "reload")
|
||||
assert.equal(calls.length, 0)
|
||||
@@ -803,14 +579,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, /<image v-if="nativeBackdropReady" :href="state\.backdrop\.url"/)
|
||||
assert.doesNotMatch(editorSource, /devicePreviewOpen|gx-layout__device|selectPreview/)
|
||||
assert.match(editorSource, /<section v-if="state\.devicePreviewOpen" class="gx-layout__device"/)
|
||||
assert.match(editorSource, /<img v-if="state\.preview\.url"/)
|
||||
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, device backdrop, placement, drag/cancel, resets, save races, errors, auth and Visual navigation passed")
|
||||
console.log("Colors and layout: stable editor, explicit Device preview, placement, drag/cancel, resets, save races, errors, auth and Visual navigation passed")
|
||||
|
||||
const railEditor = editor().vm
|
||||
railEditor.selectProfile("compact")
|
||||
|
||||
@@ -46,7 +46,6 @@ 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():
|
||||
@@ -60,7 +59,6 @@ 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,17 +11,14 @@ 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: 75 }, backgroundToggle: true,
|
||||
opacity: { min: 25, max: 100, default: 100 }, 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: 75, background: false } }
|
||||
default: { x: 30, y: 954, enabled: true, opacity: 100, background: true } }
|
||||
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: 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 doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: 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-screen-and-layout", editable: true, valid: true,
|
||||
@@ -29,16 +26,6 @@ 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)
|
||||
@@ -59,9 +46,6 @@ 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
|
||||
@@ -69,9 +53,7 @@ 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'/)
|
||||
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\)/)
|
||||
assert.match(OnroadLayoutPage.template, /Large UI Gamma Effect Trial/)
|
||||
const gammaChanges = []
|
||||
const gammaVm = { projection: true, editable: true,
|
||||
state: { draft: { largeUiGammaTrial: false }, drag: null, layerDrag: null, notice: '' },
|
||||
@@ -88,33 +70,10 @@ 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", "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)
|
||||
for (const key of ["blindSpotBorder", "signalBorder", "hidePersonalityAlerts", "wheelPedalFeedback"]) assert.equal(validDocument({ ...data.document, [key]: 1 }, data.metadata), false)
|
||||
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) }
|
||||
@@ -136,7 +95,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,
|
||||
@@ -188,7 +147,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 },
|
||||
$emit: (...args) => emissions.push(args), feed: { load() {}, async save() { return true } } }
|
||||
hideDevicePreview() {}, $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"])
|
||||
@@ -233,8 +192,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, 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.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.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: 3, clock24Hour: false, canvas: { width: 2880, height: 1080 }, widgets }
|
||||
const doc = { version: 2, 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,7 +95,6 @@ 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 },
|
||||
@@ -183,7 +182,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('Off')
|
||||
vm.setWidgetBackground({target: {checked: false}})
|
||||
assert.equal(vm.layout[key].opacity, 25 + index * 10)
|
||||
assert.equal(vm.layout[key].background, false)
|
||||
vm.undo()
|
||||
@@ -207,7 +206,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}, 'On')
|
||||
OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, {target: {checked: true}})
|
||||
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, 50, 'pending fragments retry after 50ms during playback')
|
||||
assert.equal(nextLoad.ms, 200, 'pending fragments use the paced retry rather than a 50ms poll')
|
||||
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:7, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"},
|
||||
const document = { version:6, 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,23 +64,6 @@ 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,PreviewQueue} from '../web/js/layout-preview.js'
|
||||
import {LayoutPreviewFeed} 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,28 +17,19 @@ 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()}
|
||||
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 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()}
|
||||
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')
|
||||
@@ -47,112 +38,15 @@ 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,'blob:1','Scene change retains art until its replacement arrives')
|
||||
feed.update(document,'compact','cem_stop_light');assert.equal(feed.currentUrl,null,'Scene change clears stale CEM art')
|
||||
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,'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()
|
||||
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'))
|
||||
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, races, retained refreshes, profile/mode switches, retries, auth and cleanup passed')
|
||||
console.log('Canonical widget art: metadata bounds, authenticated request, race, drag retention, neutral error, selection passed')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1383,8 +1383,6 @@ 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); }
|
||||
@@ -1422,9 +1420,8 @@ 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, .gx-aa-mirror-steps { padding-left: 1.3em; display: grid; gap: var(--sp-1); }
|
||||
.gx-aa-wiki ol, .gx-aa-wiki ul { 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); }
|
||||
@@ -1476,6 +1473,17 @@ 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);
|
||||
@@ -1576,6 +1584,10 @@ 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);
|
||||
|
||||
@@ -341,11 +341,18 @@ 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); }
|
||||
@@ -360,8 +367,6 @@ 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; }
|
||||
@@ -401,6 +406,9 @@ 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; }
|
||||
@@ -443,6 +451,8 @@ 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; }
|
||||
|
||||
@@ -503,8 +513,10 @@ 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; }
|
||||
@@ -536,8 +548,9 @@ 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 { 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__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__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; }
|
||||
@@ -559,7 +572,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 { max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
|
||||
.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__sections { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px)); }
|
||||
}
|
||||
|
||||
@@ -643,15 +656,6 @@ 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": "Show StarPilot on your car’s screen",
|
||||
"description": "Phone projection and connection settings",
|
||||
"availability": "local-only",
|
||||
"visibility": "authenticated"
|
||||
}
|
||||
|
||||
@@ -316,14 +316,13 @@ 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."
|
||||
|
||||
// While the comma is still trying APKPure, wait instead of sending people to APKMirror.
|
||||
export function downloadLink(release, pending = false) {
|
||||
export function downloadLink(release) {
|
||||
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 pending ? { direct: false, pending: true, url: "" } : { direct: false, url: APKMIRROR_URL }
|
||||
return { direct: false, url: APKMIRROR_URL }
|
||||
}
|
||||
const STAGES = ["reading", "searching", "decrypting", "verifying", "installing", "done"]
|
||||
const CHECKS = [
|
||||
@@ -422,7 +421,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, showSteps: false
|
||||
installFileName: "", pairWhenReady: false, enableOpen: false
|
||||
}),
|
||||
mounted() {
|
||||
this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
|
||||
@@ -448,14 +447,8 @@ 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) },
|
||||
// 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) },
|
||||
download() { return downloadLink(this.setup?.download) },
|
||||
uploadPercent() {
|
||||
const { loaded, total } = this.uploadProgress || {}
|
||||
return total > 0 ? Math.min(100, Math.round(100 * loaded / total)) : 0
|
||||
@@ -642,27 +635,7 @@ 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>
|
||||
<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">
|
||||
<ol v-else 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>
|
||||
@@ -672,17 +645,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 }}</span>
|
||||
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}<template v-if="hasPackage && !setup.enabled"> · Off</template></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-else class="gx-btn gx-btn--tonal" @click="openInstall">Install newer version</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>
|
||||
</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>
|
||||
@@ -753,7 +726,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> 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>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>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>
|
||||
@@ -801,9 +774,7 @@ 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 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>
|
||||
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/theme_maker/android_auto">Customize Dashboard</a>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -950,13 +921,32 @@ 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">{{ 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -966,26 +956,16 @@ 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') : download.pending ? 'Looking up the newest release…' : 'Get the newest release from APKMirror' }}<template v-if="installedVersion"> · You have {{ installedVersion }}</template></small>
|
||||
<small>{{ download.direct ? 'Saves the app to this ' + (onPhone ? 'phone' : 'computer') : 'Get the newest release from APKMirror' }}</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>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -70,7 +69,6 @@ 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) => {
|
||||
@@ -145,13 +143,11 @@ 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 }
|
||||
@@ -167,8 +163,6 @@ 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">
|
||||
@@ -229,7 +223,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, instance: deviceRestart.instance })" />
|
||||
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" :restarting="baseline => updateRestart.start(baseline)" />
|
||||
<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" />
|
||||
@@ -288,7 +282,6 @@ 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()
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
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")
|
||||
@@ -37,6 +23,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")) { opened = true; clearTimeout(probe); probe = null; dismiss() }
|
||||
if (document.querySelector("#galaxy-app .gx-app")) dismiss()
|
||||
else failed()
|
||||
}).catch(() => { clearTimeout(timer); failed() })
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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,9 +1,5 @@
|
||||
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" },
|
||||
@@ -16,97 +12,28 @@ 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", hiddenOn: ["compact"] },
|
||||
{ id: "slc_retained", label: "Set-speed override", hiddenOn: ["compact"] },
|
||||
{ id: "slc_pedal", label: "Using accelerator" },
|
||||
{ id: "slc_retained", label: "Set-speed override" },
|
||||
{ id: "slc_disengaged", label: "Cruise off" },
|
||||
{ id: "slc_unavailable", label: "Unavailable speed limit" },
|
||||
{ id: "slc_display", label: "Display-only speed limit", hiddenOn: ["compact"] },
|
||||
{ id: "slc_display", label: "Display-only speed limit" },
|
||||
]
|
||||
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, backdrop = false, queue = null }) {
|
||||
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art, backdrop, queue })
|
||||
urls = URL, art = false }) {
|
||||
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art })
|
||||
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
|
||||
}
|
||||
|
||||
pause() {
|
||||
stop() {
|
||||
this.active = false
|
||||
this.version++
|
||||
if (this.timer !== null) this.cancelTimer(this.timer)
|
||||
@@ -115,67 +42,38 @@ 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() {
|
||||
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()
|
||||
if (this.currentUrl) this.urls.revokeObjectURL(this.currentUrl)
|
||||
this.currentUrl = null
|
||||
this.publish({ url: null, ...(this.art ? {sprites: null} : {}) })
|
||||
}
|
||||
|
||||
start() { if (this.art) this.pause(); else this.stop(); this.active = true }
|
||||
start() { this.stop(); this.active = true }
|
||||
|
||||
// `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) {
|
||||
update(document, profile, scene, dragging = false) {
|
||||
if (!this.active || !document || !["large", "compact", "projection"].includes(profile) || !SCENES.includes(scene)) return
|
||||
const documentKey = JSON.stringify(key)
|
||||
const inputKey = JSON.stringify([documentKey, profile, scene, dragging])
|
||||
const inputKey = JSON.stringify({ document, 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 }
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
retry() {
|
||||
if (!this.active || !this.last) return
|
||||
this.inputKey = null
|
||||
this.update(this.last.document, this.last.profile, this.last.scene, false, this.last.key)
|
||||
this.update(this.last.document, this.last.profile, this.last.scene)
|
||||
}
|
||||
|
||||
async dispatch() {
|
||||
@@ -190,17 +88,15 @@ 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…"
|
||||
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 {
|
||||
try {
|
||||
await Promise.race([aborted, (async () => {
|
||||
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} : this.backdrop ? {art: "backdrop"} : {}) }), signal: request.signal })
|
||||
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : {}) }), 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) { 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.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.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()
|
||||
@@ -208,33 +104,17 @@ 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 cacheKey = item.profile + "\n" + item.scene
|
||||
const previousUrl = this.art ? this.artCache.get(cacheKey)?.url : this.currentUrl
|
||||
const previousUrl = this.currentUrl
|
||||
this.currentUrl = this.urls.createObjectURL(blob)
|
||||
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} : {}) })
|
||||
this.publish({ status: "ready", error: "", url: this.currentUrl, ...(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
|
||||
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)
|
||||
if (this.art) this.clearImage()
|
||||
this.publish({ status: "unavailable", error: failure })
|
||||
this.timer = this.later(() => { this.timer = null; this.retry() }, 5000)
|
||||
}
|
||||
} finally {
|
||||
this.cancelTimer(timeout)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
import { LEAD_INFO } from "./projection-layout.js"
|
||||
|
||||
export const PROFILES = ["large", "compact"]
|
||||
const COLORS = ["cardFill", "cardBorder", "text"]
|
||||
export const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
|
||||
@@ -105,23 +103,18 @@ export function previewPoint(event, rect, profile) {
|
||||
}
|
||||
|
||||
// Android Auto display switches, independent of the comma screen's preferences.
|
||||
// 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 const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback"]
|
||||
|
||||
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, "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 ||
|
||||
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 ||
|
||||
(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) ||
|
||||
@@ -211,13 +204,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", "cruise_limits"].includes(widget.kind) ||
|
||||
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit"].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 && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) ||
|
||||
if (widget.resizable && (widget.kind !== "steering_wheel" || !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 ||
|
||||
|
||||
@@ -2,47 +2,33 @@ 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, PreviewQueue, previewScene, sceneHidesWidget } from "./layout-preview.js"
|
||||
import { LEAD_INFO, PROJECTION_WORDMARK, editorSnapshot, projectionPayload } from "./projection-layout.js"
|
||||
import { LayoutPreviewFeed, PREVIEW_SCENES } from "./layout-preview.js"
|
||||
import { 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-v2:" + (projection ? "android-auto" : "comma")
|
||||
const previewVisibilityKey = projection => "gx-layout-preview-visibility-v1:" + (projection ? "android-auto" : "comma")
|
||||
function loadPreviewVisibility(projection) {
|
||||
const hidden = { large: {}, compact: {} }
|
||||
const hidden = { large: { stopped_timer: true }, compact: { stopped_timer: true } }
|
||||
try {
|
||||
const raw = localStorage.getItem(previewVisibilityKey(projection))
|
||||
const legacy = raw === null
|
||||
const saved = JSON.parse(raw ?? localStorage.getItem(previewVisibilityKey(projection).replace('-v2:', '-v1:')))
|
||||
const saved = JSON.parse(localStorage.getItem(previewVisibilityKey(projection)))
|
||||
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") && !(legacy && id === "stopped_timer")))
|
||||
.filter(([id, value]) => typeof value === "boolean" && !(projection && id === "car_exit")))
|
||||
}
|
||||
}
|
||||
} 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
|
||||
@@ -149,11 +135,11 @@ export const LayoutWidgetPreview = {
|
||||
methods: {
|
||||
withAlpha,
|
||||
modeLines(scene, profile) {
|
||||
const { mode, experimental } = previewScene(scene), aol = scene === "aol"
|
||||
const aol = scene === "aol"
|
||||
const experimental = ["experimental", "cem_stop_light", "cem_lead", "cem_curve"].includes(scene)
|
||||
if (profile === "compact" && (aol || experimental)) return aol ? ["Always On", "Lateral"] : ["ACC -", "Experimental"]
|
||||
return [mode]
|
||||
return [aol ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill"]
|
||||
},
|
||||
pending(scene) { return scene === "general" || scene === "slc_pending" },
|
||||
},
|
||||
template: `
|
||||
<g aria-hidden="true" class="gx-layout-widget-art" :fill="palette.text || '#ffffff'">
|
||||
@@ -178,7 +164,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' && ['general', 'stopped'].includes(scene)">
|
||||
<template v-else-if="widget.kind === 'stopped_timer'">
|
||||
<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"
|
||||
@@ -193,18 +179,14 @@ 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">45</text>
|
||||
<text x="60" y="66" dominant-baseline="text-before-edge" text-anchor="middle" font-size="50" font-weight="700" fill="white">65</text>
|
||||
</template>
|
||||
<template v-else-if="widget.kind === 'speed_limit_actions'">
|
||||
<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) + ')'">
|
||||
<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) + ')'">
|
||||
<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">
|
||||
@@ -284,10 +266,10 @@ export const OnroadLayoutPage = {
|
||||
setup(props) {
|
||||
const state = reactive({
|
||||
status: "idle", data: null, draft: null, needsReload: false, error: "", notice: "",
|
||||
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 }
|
||||
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 }
|
||||
})
|
||||
let deferred = null
|
||||
const apply = (update) => {
|
||||
@@ -331,36 +313,37 @@ 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 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,
|
||||
const previewFeed = new LayoutPreviewFeed({
|
||||
unauthorized: props.unauthorized,
|
||||
publish: (update) => Object.assign(state.preview, update)
|
||||
})
|
||||
const artFeed = new LayoutPreviewFeed({ art: true, unauthorized: props.unauthorized,
|
||||
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.data?.editable], () => {
|
||||
if (props.mode !== "local" || !state.data?.editable || !state.draft) {
|
||||
artFeed.stop()
|
||||
!!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()
|
||||
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"})
|
||||
// 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()
|
||||
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()
|
||||
return
|
||||
}
|
||||
if (!backdropFeed.active) backdropFeed.start()
|
||||
state.devicePreviewOpen = true
|
||||
if (!previewFeed.active) previewFeed.start()
|
||||
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : layoutDocument(state.draft)
|
||||
backdropFeed.update(document, props.projection ? "projection" : state.profile, state.scene, false, backdropKey(state.draft))
|
||||
previewFeed.update(document, props.projection ? 'projection' : state.profile, state.scene, !!state.drag)
|
||||
}, {flush: "sync"})
|
||||
watch(() => props.mode, (mode) => {
|
||||
if (mode !== "local") { backdropFeed.stop(); artFeed.stop() }
|
||||
if (mode !== "local") { state.devicePreviewOpen = false; previewFeed.stop(); artFeed.stop() }
|
||||
})
|
||||
return { state, feed, backdropFeed, artFeed, scenes: PREVIEW_SCENES, projectionWordmark: PROJECTION_WORDMARK }
|
||||
return { state, feed, previewFeed, artFeed, scenes: PREVIEW_SCENES }
|
||||
},
|
||||
computed: {
|
||||
busy() { return ["loading", "saving"].includes(this.state.status) },
|
||||
@@ -375,25 +358,7 @@ 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() {
|
||||
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) },
|
||||
profile() { return this.state.data?.metadata.profiles[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)
|
||||
@@ -434,18 +399,6 @@ 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) =>
|
||||
@@ -486,42 +439,19 @@ export const OnroadLayoutPage = {
|
||||
this.observePinned()
|
||||
},
|
||||
updated() { this.observePinned() },
|
||||
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.backdropFeed.stop(); this.artFeed.stop(); this._stopUnloadGuard(); this._pinObserver?.disconnect(); this.unwatchRelease(); this._closeFloating?.()
|
||||
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.previewFeed.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 },
|
||||
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'
|
||||
},
|
||||
previewHidden(id) { return !(this.projection && id === "car_exit") && this.state.hiddenPreviewWidgets[this.state.profile]?.[id] === true },
|
||||
togglePreviewVisibility(id) {
|
||||
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] ||= {}
|
||||
if (this.state.drag || this.state.layerDrag || (this.projection && id === "car_exit")) return
|
||||
const hidden = this.state.hiddenPreviewWidgets[this.state.profile] ||= {}
|
||||
hidden[id] = !hidden[id]
|
||||
if (this.state.scene === "general") {
|
||||
try { localStorage.setItem(previewVisibilityKey(this.projection), JSON.stringify(this.state.hiddenPreviewWidgets)) } catch { }
|
||||
}
|
||||
try { localStorage.setItem(previewVisibilityKey(this.projection), JSON.stringify(this.state.hiddenPreviewWidgets)) } catch { }
|
||||
},
|
||||
selectWidget(id) {
|
||||
this.state.selected = id
|
||||
@@ -586,6 +516,11 @@ 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
|
||||
@@ -745,14 +680,29 @@ 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
|
||||
@@ -769,7 +719,6 @@ 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 = ""
|
||||
@@ -825,29 +774,6 @@ 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)
|
||||
@@ -863,19 +789,6 @@ 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
|
||||
@@ -930,7 +843,6 @@ 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]
|
||||
@@ -938,7 +850,7 @@ export const OnroadLayoutPage = {
|
||||
this.state.selected = id
|
||||
this.finishColorEdit()
|
||||
this.state.drag = {
|
||||
id, pointerId: event.pointerId, touch: event.pointerType === "touch", fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray,
|
||||
id, pointerId: event.pointerId, 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
|
||||
@@ -957,7 +869,7 @@ export const OnroadLayoutPage = {
|
||||
this.state.selected = id
|
||||
this.finishColorEdit()
|
||||
this.state.drag = {
|
||||
id, pointerId: event.pointerId, touch: event.pointerType === "touch", resize: true, before: { ...position }, historyBefore: clone(this.state.draft),
|
||||
id, pointerId: event.pointerId, 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
|
||||
@@ -1011,56 +923,28 @@ export const OnroadLayoutPage = {
|
||||
this.selectedPosition.opacity = value
|
||||
this.recordChange(before, this.state.profile + ":" + this.state.selected + ":opacity")
|
||||
},
|
||||
setWidgetBackground(value) {
|
||||
setWidgetBackground(event) {
|
||||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.backgroundToggle) return
|
||||
this.finishColorEdit()
|
||||
const before = clone(this.state.draft)
|
||||
this.selectedPosition.background = value === 'On'
|
||||
this.selectedPosition.background = event.target.checked
|
||||
this.recordChange(before)
|
||||
this.state.notice = ""
|
||||
},
|
||||
moveDrag(event) {
|
||||
const drag = this.state.drag
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
const rect = this.$refs.preview.getBoundingClientRect(), point = previewPoint(event, rect, this.profile)
|
||||
this.state.snapGuides = []
|
||||
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
|
||||
if (!point) 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
|
||||
}
|
||||
if (drag.resize) { this.resizeBox(drag.id, point.x - drag.offsetX, point.y - drag.offsetY); 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 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)
|
||||
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)
|
||||
}
|
||||
if (drag.fromTray) this.layout[drag.id].enabled = drag.inside
|
||||
},
|
||||
@@ -1076,7 +960,6 @@ 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
|
||||
@@ -1201,9 +1084,9 @@ export const OnroadLayoutPage = {
|
||||
leave(action) {
|
||||
this.state.discard = null
|
||||
this.state.leavePresentation = null
|
||||
if (typeof action === "function") action()
|
||||
else if (action === "back") this.$emit("close")
|
||||
else if (["projection", "device"].includes(action)) this.$emit("target", action)
|
||||
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) }
|
||||
else this.feed.load()
|
||||
},
|
||||
save() { if (this.editable && this.dirty && !this.state.drag && !this.state.layerDrag) return this.feed.save(this.state.draft) },
|
||||
@@ -1239,7 +1122,6 @@ 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>
|
||||
@@ -1266,29 +1148,25 @@ 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 : state.art.status === 'updating' || state.backdrop.status === 'updating' ? 'Updating preview…' : profile.width + " × " + profile.height }}</span></div>
|
||||
<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__preview-bar">
|
||||
<GalaxySelect class="gx-field gx-field--full" aria-label="Preview scene" :value="state.scene" :disabled="!!state.drag || !!state.layerDrag" @change="state.scene = $event.target.value">
|
||||
<optgroup v-for="group in sceneGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="scene in group.scenes" :key="scene.id" :value="scene.id">{{ scene.label }}</option>
|
||||
</optgroup>
|
||||
</GalaxySelect>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.snapEnabled" :disabled="!!state.drag || !!state.layerDrag" aria-label="Snap to edges and centers" title="Snap to edges and centers" @click="toggleSnap"><i class="bi bi-magnet" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showEditingAids" aria-label="Show editing aids" title="Show editing aids" @click="toggleEditingAids"><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
|
||||
<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>
|
||||
<button v-if="!projection && profile.inputZones?.length" class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-pressed="state.showFavoriteZones" aria-label="Show Favorite tap areas" title="Show Favorite tap areas" @click="state.showFavoriteZones = !state.showFavoriteZones"><i class="bi bi-grid-3x3-gap" aria-hidden="true"></i></button>
|
||||
<button class="gx-icon-btn gx-layout__bar-toggle" type="button" :aria-expanded="state.tipsOpen" aria-controls="gx-layout-tips" aria-label="Preview tips and display behavior" title="Preview tips and display behavior" @click="state.tipsOpen = !state.tipsOpen"><i class="bi bi-question-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<p class="gx-note">{{ state.scene === 'general' ? 'Arrange all enabled widgets with sample readings. Use a driving scene to check when they appear.' : 'Eyes follow this driving scene. Choose General edit to arrange widgets hidden here.' }}</p>
|
||||
<p v-if="state.backdrop.status === 'unavailable'" class="gx-note" role="status">{{ nativeBackdropReady ? 'The comma screen could not refresh. Showing the last one.' : 'The comma screen is unavailable. Showing a sketch.' }}</p>
|
||||
<p v-if="state.art.status === 'unavailable'" class="gx-note" role="status">{{ state.art.url ? 'Widget preview could not refresh. Showing the last preview.' : 'Widget preview unavailable. Placement controls remain available.' }}</p>
|
||||
<div 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>
|
||||
<div class="gx-layout__stage">
|
||||
<span v-if="state.showEditingAids && state.snapGuides.length && !snapInBar" class="gx-layout__stage-chip gx-layout__stage-chip--start" :class="{'gx-layout__stage-chip--with-tap-areas': !projection && state.showFavoriteZones && state.profile !== 'compact'}" aria-hidden="true">{{ snapSummary }}</span>
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile !== 'compact'" class="gx-layout__stage-chip" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<svg ref="preview" class="gx-layout__preview" :style="{'--layout-glow': state.profile === 'compact' ? '4px' : '14px'}" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
|
||||
<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"
|
||||
@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">
|
||||
@@ -1308,6 +1186,7 @@ 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" />
|
||||
@@ -1315,19 +1194,14 @@ 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>
|
||||
</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">
|
||||
<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">
|
||||
<g v-for="zone in profile.inputZones || []" :key="zone.id">
|
||||
<rect :x="zone.x" :y="zone.y" :width="zone.width" :height="zone.height" fill="#b799ff16" stroke="#b799ff88" :stroke-width="state.profile === 'large' ? 3 : 1" stroke-dasharray="5 5" />
|
||||
<text :x="zone.x + zone.width / 2" :y="zone.y + zone.height - 8" text-anchor="middle" :font-size="state.profile === 'large' ? 20 : 10" fill="#d8c8ff">{{ zone.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="widget in renderWidgets" :key="widget.id" class="gx-layout__widget" :class="{'is-selected': state.drag?.id === widget.id, 'is-editing': state.showEditingAids && state.inspectorPanel === 'edit' && state.selected === widget.id}"
|
||||
<g v-for="widget in renderWidgets" :key="widget.id" class="gx-layout__widget" :class="{'is-selected': state.drag?.id === 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.' :
|
||||
@@ -1343,29 +1217,27 @@ export const OnroadLayoutPage = {
|
||||
<path d="M-6 6 L6 -6 M0 6 L6 0" stroke="#16121f" stroke-width="3" stroke-linecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
<g v-if="projection && !nativeBackdropReady" data-preview-source="fixed-wordmark" role="img" aria-label="StarPilot, fixed logo" pointer-events="none"
|
||||
:transform="'translate(' + (profile.width - projectionWordmark.right) + ' ' + (profile.height - projectionWordmark.bottom) + ') scale(' + projectionWordmark.scale + ')'">
|
||||
<svg v-for="(glyph, index) in projectionWordmark.glyphs" :key="index" :x="glyph.offsetX" :y="glyph.offsetY"
|
||||
:width="glyph.width" :height="glyph.height" :viewBox="[glyph.x, glyph.y, glyph.width, glyph.height].join(' ')" aria-hidden="true">
|
||||
<image href="./assets/starpilot-wordmark.png" width="1024" height="512" />
|
||||
</svg>
|
||||
</g>
|
||||
<g v-if="state.showEditingAids && state.snapGuides.length" class="gx-layout__guides" aria-hidden="true" pointer-events="none">
|
||||
<line v-for="guide in state.snapGuides" :key="guide.axis + guide.value" :class="'gx-layout__guide gx-layout__guide--' + guide.kind"
|
||||
:x1="guide.axis === 'x' ? guide.value : 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" />
|
||||
<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>
|
||||
</div>
|
||||
<!-- The Small screen is too short to carry the chip over the layout; it sits underneath instead. -->
|
||||
<span v-if="!projection && state.showFavoriteZones && state.showEditingAids && state.profile === 'compact'" class="gx-layout__stage-chip gx-layout__stage-chip--below" aria-hidden="true"><i class="bi bi-grid-3x3-gap"></i>Tap areas</span>
|
||||
<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>
|
||||
<div v-show="state.tipsOpen" id="gx-layout-tips" class="gx-layout__tips">
|
||||
<p class="gx-note">Drag widgets to place them. Behind them is the comma's own screen for the chosen scene, including its border, road and alerts. Readings are samples.</p>
|
||||
<p class="gx-note">Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
|
||||
<p class="gx-note">General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.</p>
|
||||
<p class="gx-note">The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.</p>
|
||||
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.</p>
|
||||
<p 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 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>
|
||||
@@ -1382,7 +1254,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 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>
|
||||
<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>
|
||||
</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"
|
||||
@@ -1396,9 +1268,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 || sceneHidden(widget.id)"
|
||||
:aria-label="visibilityTitle(widget.id, widget.label)"
|
||||
:title="visibilityTitle(widget.id, widget.label)"
|
||||
<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'"
|
||||
: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>
|
||||
@@ -1415,7 +1287,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" :aria-label="'Drag ' + widget.label + ' onto preview'"
|
||||
<button class="gx-layout__drag-handle" type="button" :disabled="!editAvailable || state.previewMode !== 'layout'" :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>
|
||||
@@ -1429,7 +1301,6 @@ 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"
|
||||
@@ -1437,33 +1308,20 @@ 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' : selectedWidget.kind === 'cruise_limits' ? 'Background opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
|
||||
<label for="layout-widget-opacity">{{ selectedWidget.box ? 'Map opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
|
||||
<input id="layout-widget-opacity" class="gx-slider" type="range" step="1" :min="selectedWidget.opacity.min" :max="selectedWidget.opacity.max"
|
||||
:value="selectedPosition.opacity" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="opacityInput($event)" @change="finishColorEdit" @blur="finishColorEdit">
|
||||
</template>
|
||||
<GalaxySettingRow v-if="selectedWidget.backgroundToggle" :row="switchRow('Show background', selectedPosition.background === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setWidgetBackground(value)" />
|
||||
<label v-if="selectedWidget.backgroundToggle"><input type="checkbox" :checked="selectedPosition.background"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setWidgetBackground"> Show background</label>
|
||||
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
|
||||
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
|
||||
<label v-if="selectedWidget.kind === 'clock'" class="gx-layout__clock-format"><input type="checkbox" :checked="!!state.draft.clock24Hour" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setClock24Hour"> Use 24-hour time</label>
|
||||
<div v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__favorite-display">
|
||||
<span>Text alignment</span>
|
||||
<div class="gx-segment" role="group" aria-label="Cruise and speed limit text alignment">
|
||||
<button v-for="align in ['left', 'center', 'right']" :key="align" class="gx-btn" type="button"
|
||||
:class="(state.draft.speedAlign || 'left') === align ? 'gx-btn--primary' : 'gx-btn--tonal'"
|
||||
:aria-pressed="(state.draft.speedAlign || 'left') === align" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
|
||||
@click="setSpeedAlign(align)">{{ align === 'left' ? 'Left' : align === 'center' ? 'Center' : 'Right' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits'" :row="switchRow('Show Speed-Limit Sources', state.draft.speedSources === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSources', value === 'On')" />
|
||||
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits' && state.draft.speedSources === true" :row="switchRow('Show as Separate Card', state.draft.speedSourcesCard === true)" :index="1"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSourcesCard', value === 'On')" />
|
||||
<label v-if="!projection && selectedWidget.kind === 'cruise_limits'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.showMaxSpeed !== false" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setShowMaxSpeed"> Show MAX speed</label>
|
||||
<GalaxySettingRow v-if="projection && selectedWidget.kind === 'steering_wheel'" :row="switchRow('Brake and Acceleration Colors', state.draft.wheelPedalFeedback === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('wheelPedalFeedback', value)" />
|
||||
<GalaxySettingRow v-if="!projection && selectedWidget.kind === 'steering_wheel' && displayRow('ShowBrakeStatus')" :row="switchRow('Brake and Acceleration Colors', displayValue('ShowBrakeStatus') === 'On')" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag || !displayAvailable(displayRow('ShowBrakeStatus'))" :save-value="(index, value) => setDisplay('ShowBrakeStatus', value)" />
|
||||
<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>
|
||||
<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">
|
||||
@@ -1558,22 +1416,17 @@ 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', state.draft.largeUiGammaTrial)" :index="1"
|
||||
<GalaxySettingRow :row="switchRow('Large UI Gamma Effect Trial', state.draft.largeUiGammaTrial)" :index="1"
|
||||
:disabled="!editable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setLargeUiGammaTrial(value)" />
|
||||
<p class="gx-note">The large UI road-camera color effect on Android Auto (not shown in the preview).</p>
|
||||
<p class="gx-note">Gamma trial: 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,33 +1,14 @@
|
||||
// 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 !== 3 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
|
||||
if (value.version !== 2 || 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: 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) },
|
||||
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) },
|
||||
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
|
||||
}
|
||||
return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults),
|
||||
@@ -38,7 +19,7 @@ export function editorSnapshot(raw) {
|
||||
}
|
||||
|
||||
export function projectionPayload(body, metadata) {
|
||||
return { revision: body.revision, document: { version: 3,
|
||||
return { revision: body.revision, document: { version: 2,
|
||||
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
|
||||
clock24Hour: body.document.clock24Hour === true,
|
||||
largeUiGammaTrial: body.document.largeUiGammaTrial === true,
|
||||
@@ -47,11 +28,6 @@ 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) } }
|
||||
}
|
||||
|
||||
@@ -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 = 50
|
||||
const PLAY_BUFFER = .5, MAX_BUFFER_AHEAD = 1, MEDIA_INTERVAL = 200
|
||||
export const replayableSegment = segment => !!((segment.files.rlog || segment.files.qlog) && (segment.files.fcamera || segment.files.qcamera))
|
||||
export function validReplayStatus(value) {
|
||||
const ranges = value?.seekRanges
|
||||
|
||||
@@ -43,6 +43,5 @@ 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,
|
||||
instance: typeof runtime.instance === "string" ? runtime.instance : null }
|
||||
return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export class UpdateRestart {
|
||||
|
||||
start(baseline) {
|
||||
if (this.baseline) {
|
||||
this.baseline = { ...this.baseline, ...baseline, instance: this.baseline.instance ?? baseline.instance }
|
||||
this.baseline = { ...this.baseline, ...baseline }
|
||||
try { this.storage.setItem(UPDATE_RESTART_KEY, JSON.stringify(this.baseline)) } catch {}
|
||||
return
|
||||
}
|
||||
@@ -44,9 +44,7 @@ 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 restarted = typeof this.baseline.instance === "string" && typeof data.instance === "string" &&
|
||||
data.instance !== this.baseline.instance
|
||||
const returned = !request && data.updater?.state === "idle" && (changed || restarted)
|
||||
const returned = !request && data.updater?.state === "idle" && changed
|
||||
if (finished || returned) {
|
||||
this.baseline = null
|
||||
if (this.retry !== null) this.cancel(this.retry)
|
||||
|
||||
@@ -27,9 +27,7 @@ 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 ||
|
||||
!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")
|
||||
![rect.offsetX ?? 0, rect.offsetY ?? 0].every(value => Number.isFinite(value) && value > -1 && value <= 0)) throw new Error("Invalid widget art rectangle")
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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.
|
||||
@@ -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:
|
||||
stream.write(json.dumps(value, allow_nan=False, separators=(',', ':')))
|
||||
json.dump(value, stream, allow_nan=False, separators=(',', ':'))
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
|
||||
@@ -280,8 +280,6 @@ 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
|
||||
@@ -330,16 +328,17 @@ 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
|
||||
@@ -361,10 +360,9 @@ 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'],
|
||||
poll_camera=False)
|
||||
lateral_active=state.lateral_active, road_style=state.customization['roadColors']['compact'])
|
||||
else:
|
||||
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'], poll_camera=False)
|
||||
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'])
|
||||
finally:
|
||||
sm.updated = original
|
||||
self.render_receipts.update({name: receipts[name] for name in flags.read if name in receipts})
|
||||
@@ -397,7 +395,10 @@ class Compositor:
|
||||
self.camera.client, self.camera.frame, state, sm.logMonoTime[name], sample.boot_ns))
|
||||
|
||||
def _receive_primary(self):
|
||||
self.camera._poll_camera()
|
||||
if self.camera._ensure_connection():
|
||||
frame = self.camera.client.recv(timeout_ms=0)
|
||||
if frame is not None:
|
||||
self.camera.frame = frame
|
||||
self.primary_ready = self._primary_matches()
|
||||
|
||||
def _pip_layer(self, rect, state, *, submit=None):
|
||||
@@ -463,12 +464,13 @@ 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)
|
||||
@@ -484,8 +486,8 @@ class Compositor:
|
||||
frame = self.camera.frame if self._primary_matches() else None
|
||||
self._close_camera()
|
||||
self.camera = self.camera_class(stream_type=stream)
|
||||
self.ui._offroad_transition_callbacks.remove(self.camera._offroad_transition)
|
||||
# Preserve the subscriber to avoid repeated seeks while paused.
|
||||
# Preserve the already-subscribed client: replacing it after the paused
|
||||
# primer would require another seek and create an endless epoch loop.
|
||||
self.camera.client = client
|
||||
self.camera.frame = frame
|
||||
self.camera.available_streams = available
|
||||
@@ -505,36 +507,22 @@ class Compositor:
|
||||
self.render_receipts = {}
|
||||
self.last_route_frame = None
|
||||
|
||||
def _draw_rejection(self, epoch: int):
|
||||
sample = self.ui.replay_sample
|
||||
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
|
||||
if sample is None or sample.now_ns is None:
|
||||
return False
|
||||
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(cast(int, sample.now_ns) / 1e9)
|
||||
self.context.begin_frame(sample.now_ns / 1e9)
|
||||
self.placement.maintain(now_ns / 1e9)
|
||||
snapshot = self.adapter.build(ShellMode.ONROAD)
|
||||
self.primary_ready = False
|
||||
self._prepare()
|
||||
for prepare in tuple(self.gui._render_prepare):
|
||||
prepare()
|
||||
rl.begin_texture_mode(self.content)
|
||||
try:
|
||||
rl.clear_background(rl.BLACK)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
@@ -9,7 +9,6 @@ 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():
|
||||
@@ -53,7 +52,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=ClockSample(100, 100, 100, epoch=2, paused=True, valid=True))
|
||||
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(now_ns=100))
|
||||
core.last_route_frame = (2, 100)
|
||||
core.context = Mock()
|
||||
assert core.draw(2, 0, 9_000_000_000) is False
|
||||
@@ -77,7 +76,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=ClockSample(100, 100, 100, epoch=2, valid=True),
|
||||
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(now_ns=100),
|
||||
sm=SimpleNamespace(seen={'extrinsicsCalibration': False}))
|
||||
core.last_route_frame = None
|
||||
core.context = core.placement = Mock()
|
||||
@@ -96,261 +95,12 @@ 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):
|
||||
@@ -496,15 +246,7 @@ 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)
|
||||
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.camera_class = Mock(return_value=Mock())
|
||||
core._close_camera = Mock()
|
||||
core._new_view = Mock()
|
||||
core.readback = Mock(pending=False)
|
||||
@@ -515,12 +257,9 @@ 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,
|
||||
_offroad_transition_callbacks=callbacks)
|
||||
core.ui = SimpleNamespace(replay_sample=SimpleNamespace(valid=True, boot_ns=9_050_000_000), sm=sm)
|
||||
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
|
||||
@@ -566,7 +305,6 @@ 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()
|
||||
@@ -577,13 +315,11 @@ 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: Any = object.__new__(Compositor)
|
||||
core = 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,7 +304,6 @@ 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", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -358,16 +357,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, app_version: str | None = None) -> None:
|
||||
def start(self, *, path: Path | None = None, url: str = "", enabled=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, app_version), name="android_auto_identity_import", daemon=True)
|
||||
self.thread = threading.Thread(target=self._run, args=(path, url, enabled), name="android_auto_identity_import", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _run(self, path: Path | None, url: str, enabled=None, app_version: str | None = None) -> None:
|
||||
def _run(self, path: Path | None, url: str, enabled=None) -> None:
|
||||
def progress(**values):
|
||||
if enabled is not None and not enabled():
|
||||
raise IdentityImportError("Import cancelled: Android Auto is disabled", "CANCELLED")
|
||||
@@ -379,8 +378,6 @@ 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,15 +24,13 @@ 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 = 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
|
||||
FAILURE_TTL = 15 * 60
|
||||
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:
|
||||
@@ -158,10 +156,7 @@ def start_background_refresh(now=time.monotonic) -> None:
|
||||
global _cache, _in_flight, _refresh_after
|
||||
result = None
|
||||
try:
|
||||
for delay in (*RETRY_DELAYS, None):
|
||||
if (result := fetch_latest()) is not None or delay is None:
|
||||
break
|
||||
_sleep(delay)
|
||||
result = fetch_latest()
|
||||
except Exception:
|
||||
pass # The upload path must keep working when the hint is unavailable.
|
||||
finally:
|
||||
@@ -178,13 +173,6 @@ 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:
|
||||
@@ -192,10 +180,3 @@ 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, comma_display_seed
|
||||
from openpilot.starpilot.system.android_auto.projection_layout_runtime import ProjectionLayoutWatcher
|
||||
viewport = (geometry.logical_width, geometry.logical_height)
|
||||
watcher = ProjectionLayoutWatcher(viewport, display_seed=comma_display_seed)
|
||||
watcher = ProjectionLayoutWatcher(viewport)
|
||||
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,
|
||||
CEM_GAUGE_FRAME, CEM_GAUGE_INSET, SPEED_ALIGNMENTS, customization_metadata, validate_widget_order)
|
||||
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_stopped_timer import timer_size
|
||||
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
|
||||
|
||||
@@ -111,36 +111,23 @@ 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:
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
'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')
|
||||
'hidePersonalityAlerts': False, 'wheelPedalFeedback': False}
|
||||
|
||||
|
||||
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, 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
|
||||
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
|
||||
# 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,
|
||||
@@ -148,26 +135,14 @@ def projection_appearance(appearance, document):
|
||||
camera_view=CameraViewChoice.STANDARD, driver_camera_on_reverse=False)
|
||||
|
||||
|
||||
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):
|
||||
def default_layout(screen):
|
||||
geometry = screen_geometry(screen)
|
||||
return default_layout_for_viewport((geometry.logical_width, geometry.logical_height), display)
|
||||
return default_layout_for_viewport((geometry.logical_width, geometry.logical_height))
|
||||
|
||||
|
||||
def default_layout_for_viewport(viewport, display=None):
|
||||
def default_layout_for_viewport(viewport):
|
||||
metadata = layout_metadata_for_viewport(viewport)
|
||||
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')},
|
||||
return {'version': 2, 'clock24Hour': False, 'largeUiGammaTrial': False, **DISPLAY_DEFAULTS, '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()}}
|
||||
|
||||
@@ -175,8 +150,8 @@ def default_layout_for_viewport(viewport, display=None):
|
||||
def placement_size(key, widget, placement):
|
||||
if key in DISPLAY_WIDGETS and placement.get('display') == 'icons':
|
||||
return FAVORITE_ICON_SIZE, FAVORITE_ICON_SIZE
|
||||
if widget.get('resizable'):
|
||||
return (placement.get('size', widget['resizable']['default']),) * 2
|
||||
if key == 'steering_wheel':
|
||||
return placement.get('size', 192), placement.get('size', 192)
|
||||
if key == NAV_MAP:
|
||||
return placement['width'], placement['height']
|
||||
return widget['width'], widget['height']
|
||||
@@ -204,7 +179,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, 3) or
|
||||
type(value['version']) is not int or value['version'] not in (1, 2) 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}):
|
||||
@@ -217,9 +192,7 @@ 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 type(default) or
|
||||
(field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES) or
|
||||
(field == 'speedAlign' and value[field] not in SPEED_ALIGNMENTS)):
|
||||
if field in value and type(value[field]) is not bool:
|
||||
raise ValueError('Invalid projection display setting')
|
||||
result.setdefault(field, default)
|
||||
for key in set(metadata['widgets']) - set(value['widgets']):
|
||||
@@ -240,17 +213,9 @@ 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)
|
||||
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()) |
|
||||
fields = ({'x', 'y', 'enabled'} | ({'size'} if key == 'steering_wheel' 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')
|
||||
@@ -261,14 +226,11 @@ 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%')
|
||||
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')
|
||||
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 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
|
||||
@@ -276,10 +238,6 @@ 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.
|
||||
@@ -292,10 +250,6 @@ 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
|
||||
@@ -351,14 +305,4 @@ 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,28 +12,10 @@ from openpilot.starpilot.system.android_auto.projection_layout import (
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
def load_projection_layout(viewport, source=None):
|
||||
raw, readable = read_saved(source or ProjectionLayoutSource(), DOCUMENT_KEY, MAX_BYTES)
|
||||
if not readable:
|
||||
if not readable or raw is None:
|
||||
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):
|
||||
@@ -46,10 +28,9 @@ 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, display_seed=None):
|
||||
def __init__(self, viewport, source=None):
|
||||
self.viewport = viewport
|
||||
self.source = source or ProjectionLayoutSource()
|
||||
self.display_seed = display_seed
|
||||
self._stamp = None
|
||||
self._lock = threading.Lock()
|
||||
self._pending = None
|
||||
@@ -90,7 +71,7 @@ class ProjectionLayoutWatcher:
|
||||
|
||||
def load(self):
|
||||
self._stamp = self._current_stamp()
|
||||
return load_projection_layout(self.viewport, self.source, self.display_seed)
|
||||
return load_projection_layout(self.viewport, self.source)
|
||||
|
||||
def poll(self):
|
||||
"""Background thread only: the new layout after a save, or None. An unreadable file is retried."""
|
||||
@@ -105,7 +86,7 @@ class ProjectionLayoutWatcher:
|
||||
return None
|
||||
self._stamp = stamp
|
||||
if raw is None:
|
||||
return _unsaved_layout(self.viewport, self.display_seed)
|
||||
return default_layout_for_viewport(self.viewport)
|
||||
try:
|
||||
return decode_layout_for_viewport(raw, self.viewport)
|
||||
except (ValueError, TypeError, UnicodeError, OverflowError, RecursionError):
|
||||
|
||||
@@ -12,7 +12,6 @@ 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'))
|
||||
|
||||
@@ -81,23 +80,6 @@ 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,
|
||||
@@ -105,7 +87,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, standby_logo=standby_logo)
|
||||
favorites=ProjectionFavorites)
|
||||
|
||||
|
||||
class ProjectionOnroad:
|
||||
@@ -128,8 +110,6 @@ 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()
|
||||
@@ -197,11 +177,7 @@ class ProjectionOnroad:
|
||||
self.onroad.close()
|
||||
|
||||
def _camera_layer(self, rect, state):
|
||||
# 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)
|
||||
self.camera.render_camera_model_layer(rect, road_style=state.customization['roadColors']['large'])
|
||||
# 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
|
||||
@@ -345,39 +321,11 @@ class ProjectionOnroad:
|
||||
|
||||
def _standby(self):
|
||||
rl = self.native.rl
|
||||
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
|
||||
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)
|
||||
|
||||
def render(self):
|
||||
ui = self.native.ui_state
|
||||
|
||||
@@ -237,25 +237,12 @@ 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", "app_version": "16.2.1-release"}, directory)
|
||||
apk_identity.install_identity({"phone-cert.pem": cert, "phone-key.pem": key_pem, "root-cert.pem": root}, {"subject": "O=CarService"}, 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,7 +112,6 @@ 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):
|
||||
@@ -142,7 +141,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, 1, 1] # three tries before giving up
|
||||
assert run_refresh(monkeypatch, None, clock) == [1]
|
||||
assert apkpure.download_status(now=NOW) is None
|
||||
assert apkpure._refresh_after == pytest.approx(clock[0] + apkpure.FAILURE_TTL)
|
||||
clock[0] += apkpure.FAILURE_TTL
|
||||
@@ -150,29 +149,6 @@ 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 = []
|
||||
@@ -188,11 +164,4 @@ def test_refresh_is_single_flight(monkeypatch, cache):
|
||||
for thread in threading.enumerate():
|
||||
if thread.name == "aa-apkpure":
|
||||
thread.join(2)
|
||||
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
|
||||
assert calls == [1]
|
||||
|
||||
@@ -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,42 +30,40 @@ 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: dict[str, Any] = {'ui_state': NS(started=True, started_frame=1, sm=NS(logMonoTime={'carState': 50})),
|
||||
'display_message': Mock(return_value=object()), 'cast': cast}
|
||||
namespace = {'BOOKMARK': BOOKMARK, 'ui_state': NS(started=True, started_frame=1, sm=NS(logMonoTime={'carState': 50})),
|
||||
'current_message': Mock(return_value=object())}
|
||||
exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
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.
|
||||
def test_request_dispatches_only_the_native_bookmark_action(transport):
|
||||
receiver, sender = transport
|
||||
namespace = native_poll()
|
||||
publish = Mock()
|
||||
session = NS(_projection_bookmarks=receiver, _publish_bookmark=publish,
|
||||
_favorite_authority=Mock(side_effect=AssertionError('the comma screen does not gate car taps')))
|
||||
invoke = Mock(return_value=True)
|
||||
session = NS(_projection_bookmarks=receiver, _favorite_authority=lambda: True,
|
||||
_native_favorite_actions=lambda: {BOOKMARK: NS(available=True, invoke=invoke)})
|
||||
with patch('time.monotonic_ns', return_value=100):
|
||||
assert sender.send(50)
|
||||
namespace['_poll_projection_bookmark'](session, 101)
|
||||
publish.assert_called_once_with()
|
||||
invoke.assert_called_once_with()
|
||||
namespace['_poll_projection_bookmark'](session, 102)
|
||||
publish.assert_called_once_with()
|
||||
invoke.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('block', ['offroad', 'stale_car', 'replay', 'no_publisher'])
|
||||
def test_native_ui_consumes_requests_it_cannot_bookmark(transport, block):
|
||||
@pytest.mark.parametrize('block', ['offroad', 'authority', 'stale_car', 'unavailable_action'])
|
||||
def test_native_ui_rechecks_authority_and_consumes_blocked_requests(transport, block):
|
||||
receiver, sender = transport
|
||||
namespace = native_poll()
|
||||
publish = Mock()
|
||||
session = NS(_projection_bookmarks=receiver, _publish_bookmark=None if block == 'no_publisher' else publish)
|
||||
invoke = Mock()
|
||||
session = NS(_projection_bookmarks=receiver, _favorite_authority=lambda: block != 'authority',
|
||||
_native_favorite_actions=lambda: {BOOKMARK: NS(available=block != 'unavailable_action', invoke=invoke)})
|
||||
namespace['ui_state'].started = block != 'offroad'
|
||||
if block == 'replay':
|
||||
namespace['ui_state'].replay_clock = object()
|
||||
if block == 'stale_car':
|
||||
namespace['display_message'].return_value = None
|
||||
namespace['current_message'].return_value = None
|
||||
with patch('time.monotonic_ns', return_value=100):
|
||||
assert sender.send(50)
|
||||
namespace['_poll_projection_bookmark'](session, 101)
|
||||
publish.assert_not_called()
|
||||
invoke.assert_not_called()
|
||||
assert receiver.drain(102, 50, authorized=True) == 0
|
||||
|
||||
|
||||
|
||||
@@ -18,33 +18,6 @@ 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)
|
||||
@@ -130,7 +103,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', 'leadIndicator'):
|
||||
for key in ('blindSpotBorder', 'signalBorder', 'hidePersonalityAlerts', 'wheelPedalFeedback'):
|
||||
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)
|
||||
@@ -139,17 +112,6 @@ 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)
|
||||
@@ -200,38 +162,6 @@ 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,28 +77,6 @@ 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')
|
||||
@@ -307,35 +285,6 @@ 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,7 +21,6 @@ spec.loader.exec_module(projection)
|
||||
|
||||
class FakeGraphics:
|
||||
BLACK = object()
|
||||
WHITE = object()
|
||||
|
||||
@staticmethod
|
||||
def Color(*values): return values
|
||||
@@ -38,9 +37,6 @@ class FakeGraphics:
|
||||
@staticmethod
|
||||
def draw_rectangle_rounded_lines_ex(*_): pass
|
||||
|
||||
@staticmethod
|
||||
def draw_texture(*_): pass
|
||||
|
||||
@staticmethod
|
||||
def unload_texture(*_): pass
|
||||
|
||||
@@ -154,30 +150,6 @@ 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,12 +2,10 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -31,22 +29,6 @@ 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,8 +15,6 @@ 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]:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Large speed stack dimensions shared by rendering, input and layout metadata."""
|
||||
from functools import lru_cache
|
||||
|
||||
WIDTH = 320
|
||||
WIDTH = 344
|
||||
MAX_ROW_HEIGHT = 210
|
||||
LIMIT_ROW_HEIGHT = 260
|
||||
CARD_HEIGHT = MAX_ROW_HEIGHT + LIMIT_ROW_HEIGHT
|
||||
@@ -9,7 +10,8 @@ ACTION_X = 88
|
||||
ACTION_Y = 75 + CARD_HEIGHT + 22
|
||||
ACTION_HEADER_HEIGHT = 44
|
||||
GAP = 8
|
||||
SOURCE_ROW_HEIGHT = 76
|
||||
TOGGLE_HEIGHT = 44
|
||||
SOURCE_ROW_HEIGHT = 56
|
||||
PADDING = 24
|
||||
|
||||
|
||||
@@ -17,8 +19,88 @@ def card_height(mode):
|
||||
return CARD_HEIGHT if mode == 'split' else LIMIT_ROW_HEIGHT if mode == 'limit_only' else MAX_ROW_HEIGHT
|
||||
|
||||
|
||||
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
|
||||
@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
|
||||
|
||||
@@ -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 draw_monitor_art, monitor_bounds, monitor_visible
|
||||
from openpilot.starpilot.ui.onroad_dm import 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,10 +67,9 @@ 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")
|
||||
# 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')
|
||||
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')
|
||||
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,
|
||||
@@ -94,7 +93,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], layout_sample=True) if scene in CEM_SCENE_REASONS else None)
|
||||
visual_preview=OnroadVisualPreview(cem_reason=CEM_SCENE_REASONS[scene]) if scene in CEM_SCENE_REASONS else None)
|
||||
|
||||
if scene == 'cem_lead':
|
||||
state = replace(state, conditional_lead=ConditionalLead(12.0, 0.0))
|
||||
@@ -181,11 +180,17 @@ 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
|
||||
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)
|
||||
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)
|
||||
|
||||
def close(self) -> None:
|
||||
if rl.is_window_ready():
|
||||
@@ -261,8 +266,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, *, general: bool = False) -> None:
|
||||
top_icons = (not general and self.profile == Profile.COMPACT and state.alert.size == AlertSize.NONE and
|
||||
def _render_driver_monitor(self, rect: rl.Rectangle, state: OnroadState) -> None:
|
||||
top_icons = (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"])
|
||||
@@ -289,11 +294,9 @@ 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)
|
||||
@@ -308,16 +311,13 @@ class _Canvas:
|
||||
finally:
|
||||
rl.unload_image(image)
|
||||
|
||||
def render_widget_art(self, state: OnroadState, positions: dict, *, general: bool = False) -> bytes:
|
||||
def render_widget_art(self, state: OnroadState, positions: dict) -> 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
|
||||
# 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
|
||||
content = (rl.Rectangle(30, 30, canvas_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,15 +328,6 @@ 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
|
||||
@@ -352,15 +343,7 @@ class _Canvas:
|
||||
rl.rl.rlSetBlendFactorsSeparate(0x0302, 0x0303, 1, 0x0303, 0x8006, 0x8006)
|
||||
rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM_SEPARATE)
|
||||
try:
|
||||
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)
|
||||
self.view._ordered_widgets(content, state, only_widget=key)
|
||||
finally:
|
||||
rl.end_blend_mode()
|
||||
finally:
|
||||
@@ -405,12 +388,21 @@ 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
|
||||
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 _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))
|
||||
|
||||
def close(self) -> None:
|
||||
if self.map is not None:
|
||||
@@ -442,7 +434,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 not (payload["art"] is True or payload["art"] == "backdrop"):
|
||||
if "art" in payload and payload["art"] is not True:
|
||||
raise ValueError("Invalid widget art request")
|
||||
viewport = None
|
||||
if payload['profile'] == 'projection':
|
||||
@@ -469,12 +461,7 @@ 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, 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_widget_art(state, positions)
|
||||
return self._canvas.render(state, scene)
|
||||
|
||||
def expire(self) -> None:
|
||||
|
||||
@@ -65,3 +65,5 @@ 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(('general', 'engaged', 'aol', 'long_only', 'experimental', 'braking', 'stopped',
|
||||
SCENES = frozenset(('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 not (value['art'] is True or value['art'] == 'backdrop'):
|
||||
if 'art' in value and value['art'] is not True:
|
||||
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': value['art']} if 'art' in value else {})}
|
||||
**({'art': True} if value.get('art') is True else {})}
|
||||
|
||||
|
||||
def validate_png(value: object) -> bytes:
|
||||
|
||||
@@ -147,6 +147,8 @@ 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)
|
||||
@@ -198,7 +200,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 or preview.layout_sample:
|
||||
if preview is None:
|
||||
return
|
||||
flags = []
|
||||
if preview.cem_reason is not None:
|
||||
@@ -422,9 +424,7 @@ class OnroadView:
|
||||
self.fonts.draw(label, FontRole.SEMI_BOLD, 29, 1390 + right_shift, 56,
|
||||
curve_color)
|
||||
mode = conditional_status(state)
|
||||
# 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:
|
||||
if mode is not None and state.alert.size != AlertSize.FULL:
|
||||
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)
|
||||
|
||||
@@ -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 CEM_GAUGE_FRAME, CEM_GAUGE_INSET, placement
|
||||
from openpilot.starpilot.ui.onroad_customization import placement
|
||||
from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadState
|
||||
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole
|
||||
|
||||
@@ -194,8 +194,7 @@ class AetherGauge:
|
||||
return
|
||||
self._lead_stopped = data.indicator_extra == 'stopped'
|
||||
self._speed_mps = state.speed_mps or 0.0
|
||||
# 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)
|
||||
rect = rl.Rectangle(saved['x'], saved['y'], 344, 300)
|
||||
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,10 +48,6 @@ 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):
|
||||
|
||||
@@ -9,18 +9,14 @@ 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, GAP, SOURCE_ROW_HEIGHT, sources_extent
|
||||
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y
|
||||
|
||||
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):
|
||||
@@ -34,7 +30,6 @@ class WidgetPlacement(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
enabled: bool
|
||||
size: NotRequired[int]
|
||||
|
||||
|
||||
class Widget(TypedDict):
|
||||
@@ -50,7 +45,6 @@ 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):
|
||||
@@ -78,10 +72,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, 88, 75),
|
||||
"cruise_limits": _widget("Cruise and speed limit", "cruise_limits", WIDTH, CARD_HEIGHT + 52, 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", 128, 128, 120, 840)}},
|
||||
"driver_monitor": _widget("Driver monitoring", "driver_monitor", 192, 192, 88, 808)}},
|
||||
"compact": {"label": "Small UI", "width": 536, "height": 240,
|
||||
"bounds": {"x": 0, "y": 0, "width": 476, "height": 240},
|
||||
"protectedWidget": "speed_limit_actions",
|
||||
@@ -96,9 +90,6 @@ 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()}
|
||||
@@ -152,20 +143,14 @@ 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'
|
||||
# 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])
|
||||
PROFILES['large']['widgets'][CEM_GAUGE_WIDGET] = _widget('CEM road gauge', CEM_GAUGE_WIDGET, 344, 300, 436, 75)
|
||||
# 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", "speed_limit_actions"),
|
||||
"compact": ("max_speed", "steering_wheel", "conditional_mode", "following_distance", "model_confidence", "driver_monitor", "speed_limit_actions"),
|
||||
"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"),
|
||||
}
|
||||
|
||||
_FRAME_COLORS = {"cardFill": "#00000000", "cardBorder": "#00000000"}
|
||||
@@ -221,8 +206,6 @@ 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"] = [
|
||||
@@ -233,10 +216,8 @@ def customization_metadata():
|
||||
for name, profile in profiles.items():
|
||||
for key, widget in profile["widgets"].items():
|
||||
widget["colors"] = dict(WIDGET_COLORS[name].get(key, {}))
|
||||
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
|
||||
profile["widgets"]["steering_wheel"]["resizable"] = {
|
||||
"min": WHEEL_SIZES[name][0], "default": WHEEL_SIZES[name][1], "max": WHEEL_SIZES[name][2]}
|
||||
for profile, data in profiles.items():
|
||||
data["widgetOrder"] = list(DEFAULT_WIDGET_ORDER[profile])
|
||||
data["runtimePreviewWidgets"] = list(RUNTIME_PREVIEW_WIDGETS[profile])
|
||||
@@ -248,15 +229,15 @@ def customization_metadata():
|
||||
|
||||
|
||||
def default_document():
|
||||
return {"version": 7, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
|
||||
return {"version": 6, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
|
||||
"widgetColors": {profile: {} for profile in PROFILES}, "layouts": {
|
||||
profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})}
|
||||
profile: {key: {**widget["default"], **({"size": WHEEL_SIZES[profile][1]} if key == "steering_wheel" 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, 7):
|
||||
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):
|
||||
raise ValueError("Invalid customization version")
|
||||
if value['version'] < 6 and not _legacy_geometry:
|
||||
result = validate_document(value, _legacy_geometry=True)
|
||||
@@ -282,14 +263,6 @@ 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:
|
||||
@@ -357,14 +330,6 @@ 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:
|
||||
@@ -409,33 +374,25 @@ def validate_document(value, *, _legacy_geometry=False):
|
||||
for key, widget in data["widgets"].items():
|
||||
placement = layout[key]
|
||||
bounds = widget_bounds(profile, key)
|
||||
scalable = key == 'driver_monitor' or key == 'steering_wheel' and value['version'] >= 4
|
||||
expected = {"x", "y", "enabled"} | ({"size"} if scalable else set())
|
||||
expected = {"x", "y", "enabled"} | ({"size"} if key == "steering_wheel" and value["version"] >= 4 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 scalable:
|
||||
if key == "steering_wheel" and value["version"] >= 4:
|
||||
size = placement["size"]
|
||||
minimum, _, maximum = WIDGET_SIZES[key][profile]
|
||||
minimum, _, maximum = WHEEL_SIZES[profile]
|
||||
if type(size) is not int or not minimum <= size <= maximum:
|
||||
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
|
||||
raise ValueError("Invalid steering wheel size")
|
||||
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 in WIDGET_SIZES else widget[extent])
|
||||
if old_gauge:
|
||||
dimension = CEM_GAUGE_FRAME[0 if axis == 'x' else 1]
|
||||
size if key == 'steering_wheel' else widget[extent])
|
||||
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 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])
|
||||
result["layouts"][profile][key] = {**placement, **({"size": size} if key == "steering_wheel" else {})}
|
||||
if profile == "compact":
|
||||
actions = result["layouts"][profile]["speed_limit_actions"]
|
||||
area = data["widgets"]["speed_limit_actions"]
|
||||
@@ -445,7 +402,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 in WIDGET_SIZES else
|
||||
width, height = ((placement["size"],) * 2 if key == "steering_wheel" 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"]):
|
||||
@@ -461,10 +418,6 @@ 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:
|
||||
@@ -502,9 +455,7 @@ def placement(document, profile, widget):
|
||||
def widget_size(document, profile, widget):
|
||||
default = PROFILES[str(profile)]["widgets"][widget]
|
||||
position = placement(document, profile, widget)
|
||||
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"])
|
||||
return (position.get("size", WHEEL_SIZES[str(profile)][1]),) * 2 if widget == "steering_wheel" else (default["width"], default["height"])
|
||||
|
||||
|
||||
def offset(document, profile, widget):
|
||||
@@ -533,3 +484,15 @@ 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=(',', ':')))
|
||||
|
||||
@@ -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, widget_size
|
||||
from openpilot.starpilot.ui.onroad_customization import PROFILES, placement
|
||||
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,26 +26,11 @@ 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 - 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()
|
||||
x = 2 * bounds['x'] + bounds['width'] - x - widget['width']
|
||||
return rl.Rectangle(x, y, widget['width'], widget['height'])
|
||||
|
||||
|
||||
def monitor_visible(profile, state, *, fresh, onroad, top_icons):
|
||||
@@ -92,7 +77,8 @@ 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)
|
||||
self.renderer.set_position(bounds.x, bounds.y)
|
||||
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)
|
||||
if ready:
|
||||
self.renderer._update_state()
|
||||
else:
|
||||
@@ -101,4 +87,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")
|
||||
draw_monitor_art(self.renderer, bounds, self.profile)
|
||||
self.renderer._render(self.renderer._rect)
|
||||
|
||||
@@ -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,6 +16,7 @@ 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
|
||||
|
||||
|
||||
@@ -34,173 +35,84 @@ class UnifiedSpeedWidget:
|
||||
|
||||
def __init__(self, fonts: BitmapFonts):
|
||||
self.fonts = fonts
|
||||
self._source_drawer = SpeedSourceDrawer()
|
||||
self._source_session = None
|
||||
|
||||
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
|
||||
def collapse_sources(self):
|
||||
self._source_drawer.reset()
|
||||
|
||||
def _draw_source_contents(self, panel, state):
|
||||
rows = [row for row in state.speed_limit.source_readings if row.enabled]
|
||||
if not rows:
|
||||
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 26, rect, rect.y + 22)
|
||||
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 28, panel, panel.y + 30)
|
||||
return
|
||||
row_height = (panel.height - 32) / len(rows)
|
||||
active = state.speed_limit.accepted_source
|
||||
labels = {'dashboard': 'Dash', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
|
||||
cell = (rect.width - 2 * PADDING) / len(rows)
|
||||
labels = {'dashboard': 'Dashboard', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
|
||||
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 '–'
|
||||
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:
|
||||
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:
|
||||
size -= 1
|
||||
_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)
|
||||
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)
|
||||
|
||||
def render(self, content: rl.Rectangle, state: OnroadState) -> None:
|
||||
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.large_speed_geometry import (WIDTH, MAX_ROW_HEIGHT, PADDING, SOURCE_ROW_HEIGHT,
|
||||
card_height, source_toggle_bounds, source_panel_bounds)
|
||||
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')
|
||||
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
|
||||
rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, card_height(shown.mode))
|
||||
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)
|
||||
|
||||
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):
|
||||
def label(value, x, 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, start(*self.fonts.horizontal_ink(value, role, size)), y - ink_top, color)
|
||||
self.fonts.draw(value, role, size, x, y - ink_top, color)
|
||||
|
||||
def row(title, value, top, footer, status='', active=False, footer_color=muted, footer_y=174, height=MAX_ROW_HEIGHT):
|
||||
has_value = value != '–'
|
||||
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 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:
|
||||
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 -= 1
|
||||
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]
|
||||
|
||||
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
|
||||
if not has_value:
|
||||
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
|
||||
|
||||
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)
|
||||
if has_value:
|
||||
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)
|
||||
_, 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)
|
||||
|
||||
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, height=MAX_ROW_HEIGHT)
|
||||
active=max_active, footer_color=accent if max_active else muted)
|
||||
if shown.mode != 'max_only':
|
||||
top = rect.y + (MAX_ROW_HEIGHT if shown.mode == 'split' else 0)
|
||||
if shown.mode == 'split':
|
||||
@@ -209,14 +121,33 @@ 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, 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)
|
||||
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))
|
||||
|
||||
|
||||
class CurrentSpeedHud:
|
||||
|
||||
@@ -258,12 +258,9 @@ 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)))
|
||||
@@ -326,6 +323,20 @@ 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):
|
||||
@@ -333,7 +344,7 @@ class OnroadInput:
|
||||
|
||||
@property
|
||||
def claimed(self) -> bool:
|
||||
return self._press is not None or self._slc_press is not None
|
||||
return self._press is not None or self._slc_press is not None or self._source_press is not None
|
||||
|
||||
def press(self, x: float, y: float, state: OnroadState) -> None:
|
||||
self.cancel()
|
||||
@@ -343,6 +354,9 @@ 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()
|
||||
@@ -351,6 +365,12 @@ 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)
|
||||
@@ -366,6 +386,8 @@ 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:
|
||||
@@ -375,6 +397,7 @@ class OnroadInput:
|
||||
def cancel(self) -> None:
|
||||
self._press = None
|
||||
self._slc_press = None
|
||||
self._source_press = None
|
||||
|
||||
|
||||
class SlcStateMessage(Protocol):
|
||||
|
||||
@@ -6,8 +6,6 @@ from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import pyray as rl
|
||||
|
||||
|
||||
@@ -106,12 +104,9 @@ 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):
|
||||
@@ -174,54 +169,6 @@ 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)
|
||||
@@ -246,8 +193,6 @@ 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):
|
||||
|
||||
@@ -13,7 +13,7 @@ import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
@@ -232,6 +232,7 @@ 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
|
||||
@@ -259,8 +260,6 @@ 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
|
||||
@@ -464,15 +463,13 @@ class StarShellSession:
|
||||
receiver = getattr(self, '_projection_bookmarks', None)
|
||||
if receiver is None:
|
||||
return
|
||||
# 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)
|
||||
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)
|
||||
stamp = int(ui_state.sm.logMonoTime['carState']) if authorized else 0
|
||||
for _ in range(receiver.drain(now_ns, stamp, authorized=authorized)):
|
||||
cast('Callable[[], None]', publish)()
|
||||
action = self._native_favorite_actions().get(BOOKMARK)
|
||||
if action is not None and action.available and action.invoke is not None:
|
||||
action.invoke()
|
||||
|
||||
def _poll_wheel(self, now_ns):
|
||||
if getattr(ui_state, "replay_clock", None) is not None:
|
||||
@@ -1289,6 +1286,7 @@ 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(
|
||||
@@ -1360,6 +1358,17 @@ 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
|
||||
@@ -1772,7 +1781,6 @@ 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
|
||||
@@ -2004,7 +2012,6 @@ 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 = _
|
||||
|
||||
@@ -41,7 +41,6 @@ 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
|
||||
@@ -573,13 +572,9 @@ 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)):
|
||||
# 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))
|
||||
observation = SpeedLimitObservation(kind=ObservationKind.VALID, source=previous.source,
|
||||
speed_limit_mps=previous.speed_limit_mps,
|
||||
offset_mps=previous.offset_mps, status='display_hold')
|
||||
else:
|
||||
self._slc_sign_cache = None
|
||||
else:
|
||||
|
||||
@@ -85,6 +85,8 @@ 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:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""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
Reference in New Issue
Block a user