Apply saved Android Auto layouts live using a background file watcher.

This commit is contained in:
dirwin31
2026-10-11 21:59:29 -07:00
parent 32805df912
commit 1c8fc08089
4 changed files with 209 additions and 12 deletions
@@ -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)
@@ -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)
@@ -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
@@ -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'