mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
fda22037d7
Route controller input and cruise actions through shared transport and mode-action owners. Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
270 lines
8.8 KiB
Python
270 lines
8.8 KiB
Python
"""Bounded external Linux button observations for a separate action owner."""
|
|
|
|
from dataclasses import dataclass, field
|
|
import fcntl
|
|
import hashlib
|
|
from itertools import islice
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
import struct
|
|
import time
|
|
|
|
|
|
DEV_ROOT = Path("/dev/input")
|
|
SYS_ROOT = Path("/sys/class/input")
|
|
EVENT_NAME = re.compile(r"event[0-9]+\Z")
|
|
MODALIAS = re.compile(r"input:b([0-9a-f]{4})v([0-9a-f]{4})p([0-9a-f]{4})e([0-9a-f]{4})", re.IGNORECASE)
|
|
EVENT = struct.Struct("@llHHi")
|
|
EV_SYN, EV_KEY, EV_ABS = 0, 1, 3
|
|
SYN_DROPPED = 3
|
|
ABS_HAT0X, ABS_HAT3Y = 16, 23
|
|
HAT_EVENT_BASE = 0x10000
|
|
EXTERNAL_BUSES = frozenset((0x0003, 0x0005))
|
|
RESCAN_NS = 2_000_000_000
|
|
MAX_AGE_NS = 250_000_000
|
|
MAX_DEVICES = 16
|
|
MAX_CANDIDATES = 64
|
|
MAX_EVENTS_DEVICE = 64
|
|
MAX_EVENTS_POLL = 256
|
|
EVIOCSCLOCKID = 0x400445A0
|
|
KEY_STATE_BYTES = 64
|
|
EVIOCGKEY = 0x80004518 | (KEY_STATE_BYTES << 16)
|
|
ABS_INFO = struct.Struct("iiiiii")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Press:
|
|
device_id: str
|
|
code: int
|
|
timestamp_ns: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Identity:
|
|
path: Path
|
|
device_id: str
|
|
name: str
|
|
bus: int
|
|
|
|
|
|
@dataclass
|
|
class _Open:
|
|
identity: _Identity
|
|
fd: int
|
|
armed_keys: set[int] = field(default_factory=set)
|
|
pressed_keys: set[int] = field(default_factory=set)
|
|
neutral_hats: set[int] = field(default_factory=set)
|
|
hat_values: dict[int, int] = field(default_factory=dict)
|
|
buffer: bytearray = field(default_factory=bytearray)
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8", errors="replace").strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _bits(raw: str) -> set[int]:
|
|
try:
|
|
words = raw.split()
|
|
if not words or any(not re.fullmatch(r"[0-9a-fA-F]{1,16}", word) for word in words):
|
|
return set()
|
|
width = struct.calcsize("L") * 8
|
|
return {index * width + bit for index, word in enumerate(reversed(words))
|
|
for bit in range(width) if int(word, 16) & (1 << bit)}
|
|
except (TypeError, ValueError):
|
|
return set()
|
|
|
|
|
|
def _button_capable(sysfs: Path) -> bool:
|
|
capabilities = sysfs / "capabilities"
|
|
keys = _bits(_text(capabilities / "key"))
|
|
axes = _bits(_text(capabilities / "abs"))
|
|
relative = _bits(_text(capabilities / "rel"))
|
|
props = _bits(_text(sysfs / "properties"))
|
|
if 1 in props or 2 in props or 3 in props or 0x14A in keys:
|
|
return False
|
|
if 0 in relative or 1 in relative or any(0x2F <= axis <= 0x3D for axis in axes):
|
|
return False
|
|
if any(0x110 <= code <= 0x117 for code in keys):
|
|
return False
|
|
return any(0 < code < 0x110 or 0x120 <= code <= 0x14F for code in keys) or \
|
|
any(ABS_HAT0X <= axis <= ABS_HAT3Y for axis in axes)
|
|
|
|
|
|
def _inspect(path: Path) -> _Identity | None:
|
|
if not EVENT_NAME.fullmatch(path.name):
|
|
return None
|
|
sysfs = SYS_ROOT / path.name / "device"
|
|
match = MODALIAS.match(_text(sysfs / "modalias"))
|
|
if match is None:
|
|
return None
|
|
bus, vendor, product, _version = (int(token, 16) for token in match.groups())
|
|
if bus not in EXTERNAL_BUSES or not _button_capable(sysfs):
|
|
return None
|
|
name = _text(sysfs / "name") or "External input"
|
|
uniq = _text(sysfs / "uniq")
|
|
identity = f"{bus:04x}:{vendor:04x}:{product:04x}:{name.casefold()}:{uniq.casefold()}"
|
|
return _Identity(path, hashlib.sha256(identity.encode()).hexdigest()[:20], name[:80], bus)
|
|
|
|
|
|
def _fresh(seconds: int, microseconds: int, now_ns: int) -> int | None:
|
|
if seconds < 0 or not 0 <= microseconds < 1_000_000:
|
|
return None
|
|
stamp = seconds * 1_000_000_000 + microseconds * 1_000
|
|
return stamp if 0 <= now_ns - stamp <= MAX_AGE_NS else None
|
|
|
|
|
|
def _hat_code(axis: int, value: int) -> int:
|
|
return HAT_EVENT_BASE + (axis - ABS_HAT0X) * 2 + int(value > 0)
|
|
|
|
|
|
class InputReader:
|
|
def __init__(self):
|
|
self._opened: dict[Path, _Open] = {}
|
|
self._next_scan_ns = 0
|
|
self._closed = False
|
|
|
|
def _remove(self, path: Path) -> None:
|
|
opened = self._opened.pop(path, None)
|
|
if opened is not None:
|
|
try:
|
|
os.close(opened.fd)
|
|
except OSError:
|
|
pass
|
|
|
|
def _open(self, identity: _Identity) -> None:
|
|
flags = os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC | os.O_NOFOLLOW
|
|
try:
|
|
fd = os.open(identity.path, flags)
|
|
try:
|
|
if not stat.S_ISCHR(os.fstat(fd).st_mode):
|
|
return
|
|
fcntl.ioctl(fd, EVIOCSCLOCKID, struct.pack("i", time.CLOCK_MONOTONIC))
|
|
opened = _Open(identity, fd)
|
|
try:
|
|
state = fcntl.ioctl(fd, EVIOCGKEY, bytes(KEY_STATE_BYTES))
|
|
if len(state) == KEY_STATE_BYTES:
|
|
opened.armed_keys = {code for code in range(1, 0x150) if not state[code // 8] & (1 << (code % 8))}
|
|
except OSError:
|
|
pass
|
|
for axis in range(ABS_HAT0X, ABS_HAT3Y + 1):
|
|
try:
|
|
state = fcntl.ioctl(fd, 0x80184540 + axis, bytes(ABS_INFO.size))
|
|
if len(state) == ABS_INFO.size and ABS_INFO.unpack(state)[0] == 0:
|
|
opened.neutral_hats.add(axis)
|
|
opened.hat_values[axis] = 0
|
|
except OSError:
|
|
pass
|
|
self._opened[identity.path] = opened
|
|
fd = -1
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
except OSError:
|
|
return
|
|
|
|
def _rescan(self, now_ns: int) -> None:
|
|
self._next_scan_ns = now_ns + RESCAN_NS
|
|
try:
|
|
candidates = list(islice((path for path in DEV_ROOT.glob("event*") if EVENT_NAME.fullmatch(path.name)), MAX_CANDIDATES + 1))
|
|
if len(candidates) > MAX_CANDIDATES:
|
|
candidates = []
|
|
else:
|
|
candidates.sort()
|
|
except OSError:
|
|
candidates = []
|
|
identities = [identity for path in candidates if (identity := _inspect(path)) is not None]
|
|
counts: dict[str, int] = {}
|
|
for identity in identities:
|
|
counts[identity.device_id] = counts.get(identity.device_id, 0) + 1
|
|
allowed = {identity.path: identity for identity in identities if counts[identity.device_id] == 1}
|
|
for path, opened in list(self._opened.items()):
|
|
if path not in allowed or opened.identity != allowed[path]:
|
|
self._remove(path)
|
|
for path, identity in allowed.items():
|
|
if len(self._opened) >= MAX_DEVICES:
|
|
break
|
|
if path not in self._opened:
|
|
self._open(identity)
|
|
|
|
def _decode(self, opened: _Open, raw: bytes, now_ns: int) -> Press | None:
|
|
seconds, microseconds, event_type, code, value = EVENT.unpack(raw)
|
|
if event_type == EV_SYN and code == SYN_DROPPED:
|
|
self._remove(opened.identity.path)
|
|
return None
|
|
stamp = _fresh(seconds, microseconds, now_ns)
|
|
if stamp is None:
|
|
return None
|
|
if event_type == EV_KEY:
|
|
if value == 0:
|
|
opened.pressed_keys.discard(code)
|
|
opened.armed_keys.add(code)
|
|
elif value == 1 and code in opened.armed_keys and code not in opened.pressed_keys:
|
|
opened.pressed_keys.add(code)
|
|
return Press(opened.identity.device_id, code, stamp)
|
|
elif event_type == EV_ABS and ABS_HAT0X <= code <= ABS_HAT3Y and value in (-1, 0, 1):
|
|
if value == 0:
|
|
opened.neutral_hats.add(code)
|
|
opened.hat_values[code] = 0
|
|
elif code in opened.neutral_hats and value != opened.hat_values.get(code):
|
|
opened.hat_values[code] = value
|
|
return Press(opened.identity.device_id, _hat_code(code, value), stamp)
|
|
return None
|
|
|
|
def poll(self, now_ns: int) -> list[Press]:
|
|
if self._closed:
|
|
return []
|
|
if type(now_ns) is not int or now_ns < 0:
|
|
raise ValueError("Monotonic timestamp required")
|
|
if now_ns >= self._next_scan_ns or now_ns + RESCAN_NS < self._next_scan_ns:
|
|
self._rescan(now_ns)
|
|
presses: list[Press] = []
|
|
remaining = MAX_EVENTS_POLL
|
|
for path, opened in list(self._opened.items()):
|
|
if remaining == 0:
|
|
self._remove(path)
|
|
continue
|
|
try:
|
|
chunk = os.read(opened.fd, EVENT.size * (min(MAX_EVENTS_DEVICE, remaining) + 1))
|
|
except BlockingIOError:
|
|
continue
|
|
except OSError:
|
|
self._remove(path)
|
|
continue
|
|
if not chunk:
|
|
self._remove(path)
|
|
continue
|
|
opened.buffer.extend(chunk)
|
|
count = len(opened.buffer) // EVENT.size
|
|
if count > min(MAX_EVENTS_DEVICE, remaining) or len(opened.buffer) > EVENT.size * MAX_EVENTS_DEVICE:
|
|
self._remove(path)
|
|
continue
|
|
device_presses: list[Press] = []
|
|
for _ in range(count):
|
|
raw = bytes(opened.buffer[:EVENT.size])
|
|
del opened.buffer[:EVENT.size]
|
|
remaining -= 1
|
|
press = self._decode(opened, raw, now_ns)
|
|
if press is not None:
|
|
device_presses.append(press)
|
|
if path not in self._opened:
|
|
break
|
|
if path in self._opened:
|
|
presses.extend(device_presses)
|
|
return presses
|
|
|
|
def devices(self) -> list[dict]:
|
|
return [{"id": item.identity.device_id, "name": item.identity.name, "bus": item.identity.bus}
|
|
for _, item in sorted(self._opened.items())]
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
for path in list(self._opened):
|
|
self._remove(path)
|