From 15c21ec77662ba2aaeb44eb03604a6ebbe437689 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 18:36:00 -0800 Subject: [PATCH] StarPort: Keep saved layouts when an update adds or changes widgets --- .../system/starport/projection_layout.py | 53 +++++++++++++++++-- .../starport/tests/test_projection_layout.py | 20 +++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/openpilot/starpilot/system/starport/projection_layout.py b/openpilot/starpilot/system/starport/projection_layout.py index 57c936995f..c2ad66527a 100644 --- a/openpilot/starpilot/system/starport/projection_layout.py +++ b/openpilot/starpilot/system/starport/projection_layout.py @@ -7,7 +7,7 @@ from pathlib import Path from openpilot.starpilot.system.starport.identity import DATA_DIR from openpilot.starpilot.system.starport.display_profile import screen_geometry from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, JETLINK_WIDGET, MODE_WIDGET, - STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, NAV_CARD_SIZE, + STOPPED_TIMER_WIDGET, WEATHER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, NAV_CARD_SIZE, SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order) from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint @@ -223,7 +223,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5) 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}): + not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET, *PROJECTION_WIDGETS}): raise ValueError('Projection layout does not match saved screen') result = copy.deepcopy(value) if 'clock24Hour' in value and type(value['clock24Hour']) is not bool: @@ -243,7 +243,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): if 'widgetOrder' in value: order = value['widgetOrder'] if type(order) is list: - order = [*order, *(key for key in (CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS) if key not in order)] + order = [*order, *(key for key in (CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET, *PROJECTION_WIDGETS) if key not in order)] order = validate_widget_order(order, metadata['widgets']) # Older or hand-edited documents may put the escape control underneath # another widget. Preserve their order while restoring this safety layer. @@ -362,7 +362,52 @@ def decode_layout_for_viewport(raw, viewport): exit_placement = widgets.get(CAR_EXIT) if type(widgets) is dict else None if type(exit_placement) is dict and type(exit_placement.get('opacity')) is int and 0 <= exit_placement['opacity'] < CAR_EXIT_MIN_OPACITY: exit_placement['opacity'] = CAR_EXIT_MIN_OPACITY - return validate_layout_for_viewport(value, viewport) + try: + return validate_layout_for_viewport(value, viewport) + except ValueError: + return _salvage_layout(value, viewport) + + +def _salvage_layout(value, viewport): + """Keep every saved placement an update still accepts, so one stale widget does not reset the whole layout.""" + metadata = layout_metadata_for_viewport(viewport) + if type(value) is not dict or value.get('canvas') != {key: metadata[key] for key in ('width', 'height')} or type(value.get('widgets')) is not dict: + raise ValueError('Projection layout does not match saved screen') + defaults = {key: dict(widget['default']) for key, widget in metadata['widgets'].items()} + saved = {key: placement for key, placement in value['widgets'].items() if key in defaults} + result = {'version': value.get('version'), 'canvas': value['canvas'], 'widgets': dict(defaults)} + + def accepts(candidate): + try: + validate_layout_for_viewport(candidate, viewport) + return True + except (ValueError, TypeError, KeyError): + return False + + if not accepts(result): + result['version'] = 5 + for field in ('clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS): + if field in value and accepts({**result, field: value[field]}): + result[field] = value[field] + for key, placement in saved.items(): + if type(placement) is not dict: + continue + merged = {**defaults[key], **{field: placement[field] for field in placement if field in defaults[key]}} + clamped = dict(merged) + try: + bounds, size = metadata['bounds'], placement_size(key, metadata['widgets'][key], merged) + for (axis, extent), dimension in zip((('x', 'width'), ('y', 'height')), size, strict=True): + clamped[axis] = max(bounds[axis], min(clamped[axis], bounds[axis] + bounds[extent] - dimension)) + except (KeyError, TypeError, ValueError): + pass + for candidate in (placement, merged, clamped): + trial = {**result, 'widgets': {**result['widgets'], key: candidate}} + if accepts(trial): + result = trial + break + if 'widgetOrder' in value and accepts({**result, 'widgetOrder': value['widgetOrder']}): + result['widgetOrder'] = value['widgetOrder'] + return validate_layout_for_viewport(result, viewport) def projection_customization(document, base_customization): diff --git a/openpilot/starpilot/system/starport/tests/test_projection_layout.py b/openpilot/starpilot/system/starport/tests/test_projection_layout.py index 50352929a6..ab79cf237c 100644 --- a/openpilot/starpilot/system/starport/tests/test_projection_layout.py +++ b/openpilot/starpilot/system/starport/tests/test_projection_layout.py @@ -34,6 +34,26 @@ class TestProjectionLayout(unittest.TestCase): self.assertGreaterEqual(placed[axis], metadata['bounds'][axis]) self.assertLessEqual(placed[axis] + widget[extent], metadata['bounds'][axis] + metadata['bounds'][extent]) + def test_saved_layout_survives_a_new_widget_and_a_stale_one(self): + document = default_layout(SCREEN) + document['widgets']['steering_wheel'].update(x=500, y=400, enabled=False) + del document['widgets']['weather'] + document['widgets']['retired_widget'] = {'x': 0, 'y': 0, 'enabled': True} + document['widgets']['nav_card']['x'] = 99999 + decoded = decode_layout(json.dumps(document).encode(), SCREEN) + self.assertEqual({key: decoded['widgets']['steering_wheel'][key] for key in ('x', 'y', 'enabled')}, {'x': 500, 'y': 400, 'enabled': False}) + self.assertEqual(decoded['widgets']['weather'], default_layout(SCREEN)['widgets']['weather']) + self.assertNotIn('retired_widget', decoded['widgets']) + metadata = layout_metadata(SCREEN) + bounds, card = metadata['bounds'], metadata['widgets']['nav_card'] + self.assertEqual(decoded['widgets']['nav_card']['x'], bounds['x'] + bounds['width'] - card['width']) + + def test_salvage_still_rejects_a_different_screen(self): + document = default_layout(SCREEN) + document['canvas'] = {'width': 1, 'height': 1} + with self.assertRaises(ValueError): + decode_layout(json.dumps(document).encode(), SCREEN) + def test_unsaved_layer_order_draws_the_editor_default(self): document = default_layout(SCREEN) self.assertNotIn('widgetOrder', document)