mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
232 lines
7.7 KiB
Python
232 lines
7.7 KiB
Python
"""Car touchscreen input: decode Android Auto input events and map them onto the projected UI.
|
|
|
|
The head unit reports touches in its touchscreen coordinate space (advertised in
|
|
service discovery). They are mapped to the encoded video frame, then to the
|
|
visible content area inside the receiver's margins, and delivered as normalized
|
|
``(0..1, 0..1)`` single-pointer events. Only the first pointer is used: the
|
|
StarPilot UI is a single-touch interface.
|
|
|
|
Wire facts: mrmees/open-android-auto ``oaa/input`` (reference only), pinned at
|
|
61eab61c5f9968154ff1a80faa8c0a427b208479.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
from dataclasses import dataclass
|
|
|
|
from openpilot.starpilot.system.starport.wire import one, parse_fields
|
|
|
|
ACTION_PRESS = 0
|
|
ACTION_RELEASE = 1
|
|
ACTION_DRAG = 2
|
|
ACTION_POINTER_DOWN = 5
|
|
ACTION_POINTER_UP = 6
|
|
MAX_INPUT_BYTES = 16 * 1024
|
|
MAX_KEYCODES = 256
|
|
DEFAULT_TOUCH_SOCKET = "/tmp/starpilot-starport-touch.sock"
|
|
MEDIA_KEYS = {85: "play_pause", 87: "next", 88: "previous", 126: "play", 127: "pause"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TouchEvent:
|
|
kind: str # "down", "move", "up", "cancel"
|
|
x: float # normalized within the visible content area
|
|
y: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InputConfig:
|
|
keycodes: tuple[int, ...] = ()
|
|
touch_width: int = 0
|
|
touch_height: int = 0
|
|
|
|
|
|
def packed_varints(values) -> list[int]:
|
|
"""Protobuf repeated varints, packed or not."""
|
|
result: list[int] = []
|
|
for value in values:
|
|
if isinstance(value, int):
|
|
result.append(value)
|
|
continue
|
|
pos = 0
|
|
while pos < len(value):
|
|
number, shift = 0, 0
|
|
while True:
|
|
if pos >= len(value) or shift > 63:
|
|
raise ValueError("Truncated packed varint")
|
|
byte = value[pos]
|
|
pos += 1
|
|
number |= (byte & 127) << shift
|
|
shift += 7
|
|
if byte < 128:
|
|
break
|
|
result.append(number)
|
|
if len(result) > MAX_KEYCODES:
|
|
raise ValueError("Too many input keycodes")
|
|
return result
|
|
|
|
|
|
def parse_input_config(data: bytes) -> InputConfig:
|
|
fields = parse_fields(data)
|
|
keycodes = tuple(packed_varints(fields.get(1, [])))[:MAX_KEYCODES]
|
|
width = height = 0
|
|
touch = one(fields, 2)
|
|
if isinstance(touch, bytes):
|
|
screen = parse_fields(touch)
|
|
width, height = int(one(screen, 1, 0) or 0), int(one(screen, 2, 0) or 0)
|
|
return InputConfig(keycodes, width, height)
|
|
|
|
|
|
class TouchMapper:
|
|
"""Stateful single-pointer decoder for one projection session."""
|
|
|
|
def __init__(self, config: InputConfig, video_width: int, video_height: int, margin_width: int, margin_height: int):
|
|
self.touch_width = config.touch_width or video_width
|
|
self.touch_height = config.touch_height or video_height
|
|
self.video_width, self.video_height = video_width, video_height
|
|
self.left, self.top = margin_width // 2, margin_height // 2
|
|
self.content_width, self.content_height = video_width - margin_width, video_height - margin_height
|
|
self.pointer: int | None = None
|
|
|
|
def _normalize(self, x: int, y: int) -> tuple[float, float]:
|
|
vx = x * self.video_width / self.touch_width
|
|
vy = y * self.video_height / self.touch_height
|
|
nx = (vx - self.left) / self.content_width
|
|
ny = (vy - self.top) / self.content_height
|
|
return min(max(nx, 0.0), 1.0), min(max(ny, 0.0), 1.0)
|
|
|
|
def _inside(self, x: int, y: int) -> bool:
|
|
vx = x * self.video_width / self.touch_width
|
|
vy = y * self.video_height / self.touch_height
|
|
return self.left <= vx < self.left + self.content_width and self.top <= vy < self.top + self.content_height
|
|
|
|
def reset(self) -> list[TouchEvent]:
|
|
"""Focus loss or session end: withdraw a held touch rather than clicking."""
|
|
if self.pointer is None:
|
|
return []
|
|
self.pointer = None
|
|
return [TouchEvent("cancel", 0.0, 0.0)]
|
|
|
|
def decode(self, data: bytes) -> list[TouchEvent]:
|
|
if len(data) > MAX_INPUT_BYTES:
|
|
raise ValueError("Input message exceeds limit")
|
|
touch = one(parse_fields(data), 3)
|
|
if not isinstance(touch, bytes):
|
|
return [] # buttons, rotary and touchpad are not used by the projected UI
|
|
fields = parse_fields(touch)
|
|
locations = []
|
|
for raw in fields.get(1, [])[:10]:
|
|
if isinstance(raw, bytes):
|
|
loc = parse_fields(raw)
|
|
locations.append((int(one(loc, 3, 0) or 0), int(one(loc, 1, 0) or 0), int(one(loc, 2, 0) or 0)))
|
|
action = one(fields, 3, ACTION_DRAG)
|
|
index = int(one(fields, 2, 0) or 0)
|
|
if not locations:
|
|
return []
|
|
events: list[TouchEvent] = []
|
|
if action == ACTION_PRESS and self.pointer is None:
|
|
pointer, x, y = locations[0]
|
|
if self._inside(x, y):
|
|
self.pointer = pointer
|
|
events.append(TouchEvent("down", *self._normalize(x, y)))
|
|
elif self.pointer is not None:
|
|
tracked = next(((x, y) for pointer, x, y in locations if pointer == self.pointer), None)
|
|
lifted = action == ACTION_RELEASE or (action == ACTION_POINTER_UP and index < len(locations) and
|
|
locations[index][0] == self.pointer)
|
|
if tracked is None:
|
|
events.extend(self.reset())
|
|
elif lifted:
|
|
self.pointer = None
|
|
events.append(TouchEvent("up", *self._normalize(*tracked)))
|
|
elif action in (ACTION_DRAG, ACTION_POINTER_DOWN, ACTION_POINTER_UP):
|
|
events.append(TouchEvent("move", *self._normalize(*tracked)))
|
|
return events
|
|
|
|
|
|
class KeyTracker:
|
|
"""Media button presses, one action per press however long the button is held."""
|
|
|
|
def __init__(self):
|
|
self.held: set[int] = set()
|
|
|
|
def decode(self, data: bytes) -> list[str]:
|
|
if len(data) > MAX_INPUT_BYTES:
|
|
raise ValueError("Input message exceeds limit")
|
|
buttons = one(parse_fields(data), 4)
|
|
if not isinstance(buttons, bytes):
|
|
return []
|
|
actions = []
|
|
for raw in parse_fields(buttons).get(1, [])[:16]:
|
|
if not isinstance(raw, bytes):
|
|
continue
|
|
key = parse_fields(raw)
|
|
code = int(one(key, 1, 0) or 0)
|
|
if code not in MEDIA_KEYS:
|
|
continue
|
|
if not one(key, 2, 0):
|
|
self.held.discard(code)
|
|
elif code not in self.held:
|
|
self.held.add(code)
|
|
actions.append(MEDIA_KEYS[code])
|
|
return actions
|
|
|
|
|
|
class TouchSender:
|
|
"""starportd side: best-effort datagrams to the car-UI renderer."""
|
|
|
|
def __init__(self, path: str = DEFAULT_TOUCH_SOCKET):
|
|
self.path = path
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
self.sock.setblocking(False)
|
|
|
|
def send(self, events: list[TouchEvent]) -> None:
|
|
for event in events:
|
|
try:
|
|
self.sock.sendto(json.dumps({"kind": event.kind, "x": event.x, "y": event.y}).encode(), self.path)
|
|
except OSError:
|
|
pass # renderer not listening (mirror view or restarting): input is dropped, never queued
|
|
|
|
def close(self) -> None:
|
|
self.sock.close()
|
|
|
|
|
|
class TouchReceiver:
|
|
"""Renderer side: drain queued touches without blocking the render loop."""
|
|
|
|
def __init__(self, path: str = DEFAULT_TOUCH_SOCKET):
|
|
self.path = path
|
|
try:
|
|
os.unlink(path)
|
|
except FileNotFoundError:
|
|
pass
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
self.sock.bind(path)
|
|
os.chmod(path, 0o600)
|
|
self.sock.setblocking(False)
|
|
|
|
def drain(self, limit: int = 64) -> list[TouchEvent]:
|
|
events = []
|
|
while len(events) < limit:
|
|
try:
|
|
data = self.sock.recv(512)
|
|
except BlockingIOError:
|
|
break
|
|
try:
|
|
raw = json.loads(data)
|
|
kind, x, y = str(raw["kind"]), float(raw["x"]), float(raw["y"])
|
|
except (ValueError, KeyError, TypeError):
|
|
continue
|
|
if kind in ("down", "move", "up", "cancel") and 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0:
|
|
events.append(TouchEvent(kind, x, y))
|
|
return events
|
|
|
|
def close(self) -> None:
|
|
self.sock.close()
|
|
try:
|
|
os.unlink(self.path)
|
|
except FileNotFoundError:
|
|
pass
|