mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Bounded local transport for parked, main-thread layout previews."""
|
|
|
|
from collections.abc import Callable
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import stat
|
|
import struct
|
|
import threading
|
|
import time
|
|
|
|
from openpilot.starpilot.ui.onroad_customization import validate_document
|
|
|
|
|
|
DEFAULT_SOCKET_PATH = Path(f'/tmp/starpilot-layout-preview-{os.getuid()}') / 'ui.sock'
|
|
MAX_REQUEST_BYTES = 32768
|
|
MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
|
MAX_WIDTH = 2560
|
|
MAX_HEIGHT = 1440
|
|
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',
|
|
'cem_stop_light', 'cem_lead', 'cem_curve', 'slc_notice', 'slc_pending', 'slc_max', 'slc_equal',
|
|
'slc_pedal', 'slc_retained', 'slc_disengaged', 'slc_unavailable', 'slc_display'))
|
|
PROFILES = frozenset(('large', 'compact'))
|
|
RENDER_PROFILES = PROFILES | {'projection'}
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class PreviewUnavailable(RuntimeError):
|
|
pass
|
|
|
|
|
|
class PreviewBusy(PreviewUnavailable):
|
|
pass
|
|
|
|
|
|
class PreviewDenied(PermissionError):
|
|
pass
|
|
|
|
|
|
class PreviewInvalid(ValueError):
|
|
pass
|
|
|
|
|
|
class _Job:
|
|
def __init__(self, payload: dict, deadline: float):
|
|
self.payload = payload
|
|
self.deadline = deadline
|
|
self.done = threading.Event()
|
|
self.cancelled = False
|
|
self.result: bytes | None = None
|
|
self.error: Exception | None = None
|
|
|
|
|
|
def validate_preview_document(value: dict) -> dict:
|
|
if type(value) is not dict:
|
|
raise ValueError('Invalid preview document')
|
|
document = value.copy()
|
|
sidebar = document.pop('developerSidebar', False)
|
|
if type(sidebar) is not bool:
|
|
raise ValueError('Invalid preview sidebar choice')
|
|
return {**validate_document(document), **({'developerSidebar': sidebar} if 'developerSidebar' in value else {})}
|
|
|
|
|
|
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'):
|
|
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:
|
|
raise PreviewInvalid('Invalid preview profile or scene')
|
|
try:
|
|
if value['profile'] == 'projection':
|
|
from openpilot.starpilot.system.starport.projection_layout import validate_layout_for_viewport
|
|
source = value['document']
|
|
if type(source) is not dict or set(source) != {'layout', 'base'}:
|
|
raise ValueError('Invalid projection preview')
|
|
canvas = source['layout']['canvas']
|
|
document = {'layout': validate_layout_for_viewport(source['layout'], (canvas['width'], canvas['height'])),
|
|
'base': validate_document(source['base'])}
|
|
else:
|
|
document = validate_preview_document(value['document'])
|
|
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 {})}
|
|
|
|
|
|
def validate_png(value: object) -> bytes:
|
|
if type(value) is not bytes or not 45 <= len(value) <= MAX_IMAGE_BYTES or not value.startswith(PNG_SIGNATURE):
|
|
raise PreviewInvalid('Invalid preview image')
|
|
if value[8:16] != b'\x00\x00\x00\rIHDR' or value[-12:-8] != b'\x00\x00\x00\x00' or value[-8:-4] != b'IEND':
|
|
raise PreviewInvalid('Invalid preview image')
|
|
width, height = struct.unpack('!II', value[16:24])
|
|
if not 0 < width <= MAX_WIDTH or not 0 < height <= MAX_HEIGHT or width * height > MAX_WIDTH * MAX_HEIGHT:
|
|
raise PreviewInvalid('Preview dimensions exceed limit')
|
|
return value
|
|
|
|
|
|
def _recv_exact(peer: socket.socket, size: int) -> bytes:
|
|
chunks = bytearray()
|
|
while len(chunks) < size:
|
|
part = peer.recv(size - len(chunks))
|
|
if not part:
|
|
raise PreviewUnavailable('Preview connection closed')
|
|
chunks.extend(part)
|
|
return bytes(chunks)
|
|
|
|
|
|
def _send(peer: socket.socket, code: bytes, data: bytes):
|
|
peer.sendall(code + struct.pack('!I', len(data)) + data)
|
|
|
|
|
|
def _response(peer: socket.socket, timeout: float) -> bytes:
|
|
peer.settimeout(timeout)
|
|
code = _recv_exact(peer, 1)
|
|
size = struct.unpack('!I', _recv_exact(peer, 4))[0]
|
|
if size > MAX_IMAGE_BYTES:
|
|
raise PreviewUnavailable('Preview response exceeds limit')
|
|
body = _recv_exact(peer, size)
|
|
if code == b'\x00':
|
|
return body
|
|
message = body.decode('utf-8', 'replace')[:160]
|
|
if code == b'\x01':
|
|
raise PreviewInvalid(message)
|
|
if code == b'\x02':
|
|
raise PreviewDenied(message)
|
|
if code == b'\x03':
|
|
raise PreviewBusy(message)
|
|
raise PreviewUnavailable(message or 'Preview unavailable')
|
|
|
|
|
|
def request_preview(payload: dict, socket_path: str | Path | None = None, timeout: float = 2.5) -> bytes:
|
|
normalized = _payload(payload)
|
|
encoded = json.dumps(normalized, separators=(',', ':'), allow_nan=False).encode()
|
|
if len(encoded) > MAX_REQUEST_BYTES:
|
|
raise PreviewInvalid('Preview request exceeds limit')
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer:
|
|
peer.settimeout(timeout)
|
|
peer.connect(str(socket_path or DEFAULT_SOCKET_PATH))
|
|
peer.sendall(b'P' + struct.pack('!I', len(encoded)) + encoded)
|
|
return validate_png(_response(peer, timeout))
|
|
except (PreviewDenied, PreviewBusy, PreviewInvalid, PreviewUnavailable):
|
|
raise
|
|
except (OSError, TimeoutError) as error:
|
|
raise PreviewUnavailable('Onroad UI preview is unavailable') from error
|
|
|
|
|
|
def request_profile(socket_path: str | Path | None = None, timeout: float = 0.1) -> str | None:
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer:
|
|
peer.settimeout(timeout)
|
|
peer.connect(str(socket_path or DEFAULT_SOCKET_PATH))
|
|
peer.sendall(b'S')
|
|
result = json.loads(_response(peer, timeout))
|
|
profile = result.get('activeProfile')
|
|
return profile if profile in PROFILES else None
|
|
except (OSError, TimeoutError, ValueError, PreviewUnavailable):
|
|
return None
|
|
|
|
|
|
class PreviewService:
|
|
def __init__(self, renderer_callable: Callable[[dict], bytes], parked_callable: Callable[[], bool],
|
|
socket_path: str | Path | None = None, active_profile: str | None = None):
|
|
if active_profile is not None and active_profile not in PROFILES:
|
|
raise ValueError('Invalid active profile')
|
|
self.renderer = renderer_callable
|
|
self.parked = parked_callable
|
|
self.socket_path = Path(socket_path or DEFAULT_SOCKET_PATH)
|
|
self._active_profile = active_profile
|
|
self._lock = threading.Lock()
|
|
self._pending: _Job | None = None
|
|
self._rendering = False
|
|
self._last_render = -MIN_INTERVAL
|
|
self._last_error_log = float('-inf')
|
|
self._closed = threading.Event()
|
|
self._listener: socket.socket | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._socket_identity: tuple[int, int] | None = None
|
|
|
|
def set_active_profile(self, profile: str | None):
|
|
if profile is not None and profile not in PROFILES:
|
|
raise ValueError('Invalid active profile')
|
|
with self._lock:
|
|
self._active_profile = profile
|
|
|
|
def start(self):
|
|
if self._listener is not None or self._closed.is_set():
|
|
raise PreviewUnavailable('Preview service already started or closed')
|
|
parent = self.socket_path.parent
|
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
info = parent.lstat()
|
|
if info.st_uid != os.getuid() or not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077:
|
|
raise PreviewUnavailable('Preview socket directory is not private')
|
|
try:
|
|
existing = self.socket_path.lstat()
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
if existing.st_uid != os.getuid() or not stat.S_ISSOCK(existing.st_mode):
|
|
raise PreviewUnavailable('Preview socket path is occupied')
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
probe.settimeout(0.1)
|
|
try:
|
|
probe.connect(str(self.socket_path))
|
|
except (ConnectionRefusedError, FileNotFoundError):
|
|
self.socket_path.unlink()
|
|
else:
|
|
raise PreviewUnavailable('Preview service is already active')
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
listener.bind(str(self.socket_path))
|
|
bound = self.socket_path.lstat()
|
|
self._socket_identity = bound.st_dev, bound.st_ino
|
|
os.chmod(self.socket_path, 0o600)
|
|
listener.listen(1)
|
|
listener.settimeout(0.1)
|
|
except BaseException:
|
|
listener.close()
|
|
self._unlink_owned()
|
|
raise
|
|
self._listener = listener
|
|
self._thread = threading.Thread(target=self._serve, args=(listener,), name='layout-preview-io', daemon=True)
|
|
self._thread.start()
|
|
|
|
def _serve(self, listener: socket.socket):
|
|
while not self._closed.is_set():
|
|
try:
|
|
peer, _ = listener.accept()
|
|
except TimeoutError:
|
|
continue
|
|
except OSError:
|
|
break
|
|
with peer:
|
|
peer.settimeout(0.5)
|
|
try:
|
|
self._handle(peer)
|
|
except (OSError, TimeoutError, PreviewUnavailable, ValueError, UnicodeError):
|
|
pass
|
|
|
|
def _handle(self, peer: socket.socket):
|
|
opcode = _recv_exact(peer, 1)
|
|
if opcode == b'S':
|
|
with self._lock:
|
|
profile = self._active_profile
|
|
_send(peer, b'\x00', json.dumps({'activeProfile': profile}).encode())
|
|
return
|
|
if opcode != b'P':
|
|
_send(peer, b'\x01', b'Invalid preview operation')
|
|
return
|
|
size = struct.unpack('!I', _recv_exact(peer, 4))[0]
|
|
if not 0 < size <= MAX_REQUEST_BYTES:
|
|
_send(peer, b'\x01', b'Invalid preview request size')
|
|
return
|
|
try:
|
|
payload = _payload(json.loads(_recv_exact(peer, size)))
|
|
except (ValueError, UnicodeError, RecursionError):
|
|
_send(peer, b'\x01', b'Invalid preview request')
|
|
return
|
|
job = _Job(payload, time.monotonic() + RENDER_DEADLINE)
|
|
with self._lock:
|
|
if self._pending is not None or self._rendering:
|
|
_send(peer, b'\x03', b'Preview is busy; try again shortly')
|
|
return
|
|
self._pending = job
|
|
if not job.done.wait(RENDER_DEADLINE):
|
|
with self._lock:
|
|
job.cancelled = True
|
|
job.result = None
|
|
if self._pending is job:
|
|
self._pending = None
|
|
_send(peer, b'\x04', b'Onroad UI did not render in time')
|
|
return
|
|
if job.error is not None:
|
|
code = b'\x02' if isinstance(job.error, PreviewDenied) else b'\x03' if isinstance(job.error, PreviewBusy) else b'\x04'
|
|
_send(peer, code, str(job.error).encode()[:160])
|
|
elif job.result is not None:
|
|
_send(peer, b'\x00', job.result)
|
|
else:
|
|
_send(peer, b'\x04', b'Preview unavailable')
|
|
|
|
def poll(self) -> bool:
|
|
with self._lock:
|
|
job = self._pending
|
|
if job is None or job.cancelled or self._closed.is_set():
|
|
return False
|
|
completing = job.result is not None
|
|
if not completing and time.monotonic() - self._last_render < MIN_INTERVAL:
|
|
return False
|
|
self._pending = None
|
|
self._rendering = True
|
|
if not completing:
|
|
self._last_render = time.monotonic()
|
|
deferred = False
|
|
stage = 'next-frame completion' if completing else 'before render'
|
|
try:
|
|
if time.monotonic() >= job.deadline:
|
|
raise PreviewUnavailable('Preview request expired')
|
|
if not self.parked():
|
|
raise PreviewDenied('Turn off the vehicle to preview this layout')
|
|
if completing:
|
|
if self._closed.is_set() or job.cancelled:
|
|
raise PreviewUnavailable('Preview service closed or request cancelled')
|
|
if time.monotonic() >= job.deadline:
|
|
raise PreviewUnavailable('Preview rendering expired')
|
|
else:
|
|
stage = 'render'
|
|
result = validate_png(self.renderer(job.payload))
|
|
if time.monotonic() >= job.deadline:
|
|
raise PreviewUnavailable('Preview rendering expired')
|
|
# Rendering can outlast the borrowed collector's freshness window.
|
|
# Keep one bounded result until the next normal UI update, then apply
|
|
# the unchanged parked check to that frame's fresh evidence.
|
|
with self._lock:
|
|
if self._closed.is_set() or job.cancelled:
|
|
raise PreviewUnavailable('Preview service closed or request cancelled')
|
|
job.result = result
|
|
self._pending = job
|
|
deferred = True
|
|
except PreviewDenied as error:
|
|
LOGGER.info('Layout preview authority denied at %s after %.3f seconds', stage,
|
|
max(0.0, time.monotonic() - self._last_render))
|
|
job.error = error
|
|
job.result = None
|
|
except PreviewUnavailable as error:
|
|
job.error = error
|
|
job.result = None
|
|
except Exception:
|
|
now = time.monotonic()
|
|
if now - self._last_error_log >= ERROR_LOG_INTERVAL:
|
|
self._last_error_log = now
|
|
LOGGER.exception('Layout preview renderer failed')
|
|
job.error = PreviewUnavailable('Onroad UI preview is unavailable')
|
|
job.result = None
|
|
finally:
|
|
with self._lock:
|
|
self._rendering = False
|
|
if not deferred:
|
|
job.done.set()
|
|
return True
|
|
|
|
def close(self):
|
|
self._closed.set()
|
|
with self._lock:
|
|
if self._pending is not None:
|
|
self._pending.cancelled = True
|
|
self._pending.result = None
|
|
self._pending.error = PreviewUnavailable('Preview service closed')
|
|
self._pending.done.set()
|
|
self._pending = None
|
|
if self._listener is not None:
|
|
self._listener.close()
|
|
self._listener = None
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=RENDER_DEADLINE + 0.2)
|
|
self._thread = None
|
|
self._unlink_owned()
|
|
|
|
def _unlink_owned(self):
|
|
identity = self._socket_identity
|
|
self._socket_identity = None
|
|
if identity is None:
|
|
return
|
|
try:
|
|
info = self.socket_path.lstat()
|
|
except FileNotFoundError:
|
|
return
|
|
if (info.st_dev, info.st_ino) == identity:
|
|
self.socket_path.unlink()
|