diff --git a/openpilot/starpilot/system/android_auto/current_car_ui.py b/openpilot/starpilot/system/android_auto/current_car_ui.py index ff66a2ee7a..cf007109be 100644 --- a/openpilot/starpilot/system/android_auto/current_car_ui.py +++ b/openpilot/starpilot/system/android_auto/current_car_ui.py @@ -189,11 +189,14 @@ 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 load_projection_layout + from openpilot.starpilot.system.android_auto.projection_layout_runtime import ProjectionLayoutWatcher viewport = (geometry.logical_width, geometry.logical_height) - layout = ProjectionOnroad(viewport=viewport, customization=load_projection_layout(viewport), - certificate_days=certificate_days_left()) + watcher = ProjectionLayoutWatcher(viewport) + layout = ProjectionOnroad(viewport=viewport, customization=watcher.load(), + certificate_days=certificate_days_left(), layout_watcher=watcher) resources.callback(layout.close) + watcher.start() + resources.callback(watcher.close) touch = TouchReceiver(touch_path or DEFAULT_TOUCH_SOCKET) resources.callback(touch.close) control = ProjectionControlSender(control_path or DEFAULT_CONTROL_SOCKET) diff --git a/openpilot/starpilot/system/android_auto/projection_layout_runtime.py b/openpilot/starpilot/system/android_auto/projection_layout_runtime.py index 2004c4c7a9..a44d15b8ac 100644 --- a/openpilot/starpilot/system/android_auto/projection_layout_runtime.py +++ b/openpilot/starpilot/system/android_auto/projection_layout_runtime.py @@ -1,4 +1,11 @@ -"""Read a saved projection layout once per connection, never in the frame loop.""" +"""Read the saved projection layout at connect, then again only after Galaxy saves it. + +Never read on the drawing thread: a background thread stats the file once a second, +loads it only when it changed, and the renderer just takes the finished layout. +""" +import os +import threading + from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.system.android_auto.projection_layout import ( DOCUMENT_KEY, MAX_BYTES, ProjectionLayoutSource, default_layout_for_viewport, decode_layout_for_viewport, @@ -13,3 +20,74 @@ def load_projection_layout(viewport, source=None): return decode_layout_for_viewport(raw, viewport) except (ValueError, TypeError, UnicodeError, OverflowError, RecursionError): return default_layout_for_viewport(viewport) + + +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): + self.viewport = viewport + self.source = source or ProjectionLayoutSource() + self._stamp = None + self._lock = threading.Lock() + self._pending = None + self._stop = threading.Event() + self._thread = None + + def start(self): + self._thread = threading.Thread(target=self._run, name='aa-layout-watch', daemon=True) + self._thread.start() + + def close(self): + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2) + + def _run(self): + while not self._stop.wait(CHECK_SECONDS): + try: + layout = self.poll() + except Exception: + continue # a bad read must never stop live layout updates + if layout is not None: + with self._lock: + self._pending = layout + + def take(self): + """For the drawing thread: the newest saved layout not yet applied, or None. No file access.""" + with self._lock: + layout, self._pending = self._pending, None + return layout + + def _current_stamp(self): + try: + info = os.stat(self.source.get_param_path(DOCUMENT_KEY), follow_symlinks=False) + except FileNotFoundError: + return None + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns + + def load(self): + self._stamp = self._current_stamp() + 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.""" + try: + stamp = self._current_stamp() + except OSError: + return None + if stamp == self._stamp: + return None + raw, readable = read_saved(self.source, DOCUMENT_KEY, MAX_BYTES) + if not readable: + return None + self._stamp = stamp + if raw is None: + return default_layout_for_viewport(self.viewport) + try: + return decode_layout_for_viewport(raw, self.viewport) + except (ValueError, TypeError, UnicodeError, OverflowError, RecursionError): + return default_layout_for_viewport(self.viewport) diff --git a/openpilot/starpilot/system/android_auto/projection_onroad.py b/openpilot/starpilot/system/android_auto/projection_onroad.py index da9c5922a6..a91682a385 100644 --- a/openpilot/starpilot/system/android_auto/projection_onroad.py +++ b/openpilot/starpilot/system/android_auto/projection_onroad.py @@ -93,7 +93,8 @@ def native_dependencies(): class ProjectionOnroad: """Separate renderer with projected actions; native UIState stays read-only.""" - def __init__(self, *, dependencies=None, viewport=None, customization=None, certificate_days=None, native_focus=None): + def __init__(self, *, dependencies=None, viewport=None, customization=None, certificate_days=None, native_focus=None, + layout_watcher=None): viewport = FALLBACK_VIEWPORT if viewport is None else viewport self.width, self.height = viewport self.customization = customization @@ -101,6 +102,7 @@ class ProjectionOnroad: self._certificate_notice_until_ns: int | None = None self._base_customization = None self._projection_customization = None + self.layout_watcher = layout_watcher self.native_focus = native_focus or (lambda: None) self.bookmark = lambda: False self.camera_stream = None # the camera stream the last frame drew, or None @@ -132,11 +134,7 @@ class ProjectionOnroad: self.onroad.pip_layer = self._pip_layer self.map = None self.map_feed = None - if self._map_placement() is not None and getattr(native, 'map_overlay', None) is not None: - self.map = native.map_overlay(fonts=self.fonts) - self._resources.callback(self.map.close) - self.map_feed = native.map_feed() - self.onroad.map_layer = self._map_layer + self._ensure_map() self.adapter = native.adapter(native.ui_state) self._state = None self.favorites = None @@ -206,6 +204,28 @@ class ProjectionOnroad: finally: self.native.rl.rl_pop_matrix() + def _ensure_map(self): + """Build the map overlay the first time a layout turns it on; it stays for the connection.""" + native = self.native + if self.map is None and self._map_placement() is not None and getattr(native, 'map_overlay', None) is not None: + self.map = native.map_overlay(fonts=self.fonts) + self._resources.callback(self.map.close) + self.map_feed = native.map_feed() + self.onroad.map_layer = self._map_layer + + def apply_layout(self, customization): + """Switch to a newly saved Android Auto layout without reconnecting.""" + self.customization = customization + self._base_customization = self._projection_customization = None + self.camera.large_ui_gamma_trial = bool((customization or {}).get('largeUiGammaTrial', False)) + self._ensure_map() + + def _check_layout(self): + # The watcher's own thread does all file work; this only takes a finished layout. + changed = self.layout_watcher.take() if self.layout_watcher is not None else None + if changed is not None: + self.apply_layout(changed) + def _map_placement(self): """The enabled map overlay's placement from this connection's layout, or None.""" if self.customization is None: @@ -215,6 +235,7 @@ class ProjectionOnroad: def prepare(self): """Before the frame's render target is bound: offscreen map work happens here.""" + self._check_layout() if self.favorites is not None: self.favorites.refresh() self.onroad.navigation_favorites.document = self.favorites.document diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_layout_runtime.py b/openpilot/starpilot/system/android_auto/tests/test_projection_layout_runtime.py index 1b8f4b029f..f79df60918 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_projection_layout_runtime.py +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_layout_runtime.py @@ -1,17 +1,19 @@ -"""Connection-scoped saved layout and real frozen-state replacement regressions.""" +"""Saved projection layout loading, live reload after a Galaxy save, and frozen-state replacement regressions.""" import copy import fcntl import json import os from pathlib import Path import tempfile +import threading +import time from types import SimpleNamespace import unittest from unittest.mock import patch from openpilot.starpilot.system.android_auto.display_profile import record_screen, read_screen from openpilot.starpilot.system.android_auto.projection_layout import ProjectionLayoutSource, default_layout_for_viewport -from openpilot.starpilot.system.android_auto.projection_layout_runtime import load_projection_layout +from openpilot.starpilot.system.android_auto.projection_layout_runtime import ProjectionLayoutWatcher, load_projection_layout from openpilot.starpilot.system.android_auto.tests import test_projection_onroad_boundary as boundary projection = boundary.projection from openpilot.starpilot.ui.onroad_customization import default_document @@ -88,6 +90,99 @@ class TestProjectionLayoutRuntime(unittest.TestCase): self.assertEqual(connection, original) self.assertEqual(load_projection_layout((2880, 1080), source), changed) + def test_watcher_reports_each_save_once_and_skips_reads_otherwise(self): + with tempfile.TemporaryDirectory() as directory: + source = ProjectionLayoutSource(Path(directory) / 'layouts/document.json') + source.prepare() + viewport = (2880, 1080) + original = default_layout_for_viewport(viewport) + source.path.write_text(json.dumps(original)) + watcher = ProjectionLayoutWatcher(viewport, source) + self.assertEqual(watcher.load(), original) + with patch('openpilot.starpilot.system.android_auto.projection_layout_runtime.read_saved', + side_effect=AssertionError('unchanged file reread')): + self.assertIsNone(watcher.poll()) + changed = copy.deepcopy(original) + changed['widgets']['current_speed']['x'] += 60 + temporary = source.path.with_suffix('.tmp') + temporary.write_text(json.dumps(changed)) + os.replace(temporary, source.path) # Galaxy saves by atomic replace + self.assertEqual(watcher.poll(), changed) + self.assertIsNone(watcher.poll()) + source.path.write_bytes(b'x' * 16385) # unreadable: keep the current layout and retry + self.assertIsNone(watcher.poll()) + self.assertIsNone(watcher.poll()) + source.path.write_text(json.dumps(original)) + self.assertEqual(watcher.poll(), original) + source.path.unlink() + self.assertEqual(watcher.poll(), default_layout_for_viewport(viewport)) + self.assertIsNone(watcher.poll()) + + def test_background_thread_hands_over_saved_layouts_without_drawing_thread_reads(self): + with tempfile.TemporaryDirectory() as directory: + source = ProjectionLayoutSource(Path(directory) / 'layouts/document.json') + source.prepare() + viewport = (2880, 1080) + original = default_layout_for_viewport(viewport) + source.path.write_text(json.dumps(original)) + watcher = ProjectionLayoutWatcher(viewport, source) + watcher.load() + polled = threading.Event() + poll = watcher.poll + def observed_poll(): + try: + return poll() + finally: + polled.set() + watcher.poll = observed_poll + with patch('openpilot.starpilot.system.android_auto.projection_layout_runtime.CHECK_SECONDS', 0.01): + watcher.start() + try: + changed = copy.deepcopy(original) + changed['widgets']['current_speed']['x'] += 60 + temporary = source.path.with_suffix('.tmp') + temporary.write_text(json.dumps(changed)) + os.replace(temporary, source.path) + deadline = time.monotonic() + 2 + taken = None + while taken is None and time.monotonic() < deadline: + taken = watcher.take() + polled.wait(0.05) + self.assertEqual(taken, changed) + self.assertIsNone(watcher.take(), 'each save is handed over once') + finally: + watcher.close() + self.assertFalse(watcher._thread.is_alive()) + + def test_take_never_touches_files(self): + watcher = ProjectionLayoutWatcher((2880, 1080), ProjectionLayoutSource('/nonexistent/document.json')) + with patch('openpilot.starpilot.system.android_auto.projection_layout_runtime.os.stat', side_effect=AssertionError('stat')), \ + patch('openpilot.starpilot.system.android_auto.projection_layout_runtime.read_saved', side_effect=AssertionError('read')): + self.assertIsNone(watcher.take()) + + def test_renderer_applies_a_handed_over_layout(self): + from openpilot.starpilot.system.android_auto.tests.test_projection_map import FakeFeed, FakeMap + native, _ = boundary.TestProjectionOnroad().dependencies() + native.map_overlay, native.map_feed = FakeMap, FakeFeed + layout = default_layout_for_viewport((2880, 1080)) + saved = copy.deepcopy(layout) + saved['widgets']['nav_map'].update(enabled=True, x=100, y=200, width=640, height=480, opacity=45) + saved['largeUiGammaTrial'] = True + pending = [None, saved] + view = projection.ProjectionOnroad(dependencies=native, viewport=(2880, 1080), customization=layout, + layout_watcher=SimpleNamespace(take=lambda: pending.pop(0) if pending else None)) + self.addCleanup(view.close) + self.assertIsNone(view.map) + view._projection_customization = view._base_customization = object() + view.prepare() + self.assertIs(view.customization, layout) + view.prepare() + self.assertIs(view.customization, saved) + self.assertIsNone(view._projection_customization, 'the cached projected layout is rebuilt') + self.assertIs(view.camera.large_ui_gamma_trial, True) + self.assertIsInstance(view.map, FakeMap, 'turning the map on in Galaxy builds it without reconnecting') + self.assertEqual(view.onroad.map_layer, view._map_layer) + def test_screen_writer_lock_contention_preserves_previous_receipt(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'screen.json'