mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
Apply saved Android Auto layouts live using a background file watcher.
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user