Files
firestar5683 fda22037d7 Wheel Controls
Route controller input and cruise actions through shared transport and mode-action owners.

Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
2026-10-14 20:02:39 -05:00

257 lines
8.9 KiB
Python

import errno
import json
import logging
import os
from pathlib import Path
import socket
import stat
import struct
import threading
import time
DEFAULT_SOCKET_PATH = Path(f'/tmp/starpilot-controllers-{os.getuid()}') / 'ui.sock'
MAX_BYTES = 65536
REQUEST_DEADLINE = 1.0
LOGGER = logging.getLogger(__name__)
class ControllerUnavailable(RuntimeError):
pass
class ControllerBusy(ControllerUnavailable):
pass
class ControllerDenied(PermissionError):
pass
class ControllerInvalid(ValueError):
pass
def _receive(peer, count):
data = bytearray()
while len(data) < count:
part = peer.recv(count - len(data))
if not part:
raise ControllerUnavailable('Controller connection closed')
data.extend(part)
return bytes(data)
def _decode(data):
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ControllerInvalid('Duplicate controller field')
result[key] = value
return result
return json.loads(data, object_pairs_hook=unique,
parse_constant=lambda _: (_ for _ in ()).throw(ControllerInvalid('Invalid controller value')))
def _encode(value):
data = json.dumps(value, separators=(',', ':'), allow_nan=False).encode()
if not 0 < len(data) <= MAX_BYTES:
raise ControllerInvalid('Controller message exceeds limit')
return data
def _request(payload, socket_path, timeout):
encoded = _encode(payload)
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(struct.pack('!I', len(encoded)) + encoded)
size = struct.unpack('!I', _receive(peer, 4))[0]
if not 0 < size <= MAX_BYTES:
raise ControllerUnavailable('Invalid controller response size')
response = _decode(_receive(peer, size))
if type(response) is not dict or set(response) != {'status', 'result'}:
raise ControllerUnavailable('Invalid controller response')
status, result = response['status'], response['result']
if status == 'ok' and type(result) is dict:
return result
errors = {'invalid': ControllerInvalid, 'denied': ControllerDenied, 'busy': ControllerBusy}
raise errors.get(status, ControllerUnavailable)(result if type(result) is str else 'Controller buttons are unavailable')
except (ControllerInvalid, ControllerDenied, ControllerUnavailable):
raise
except (OSError, ValueError, RecursionError) as error:
raise ControllerUnavailable('Controller buttons are unavailable. Refresh to check their state.') from error
def request_status(socket_path=None, timeout=1.5):
return _request({'operation': 'status'}, socket_path, timeout)
def request_action(payload, socket_path=None, timeout=1.5):
if type(payload) is not dict or payload.get('operation') not in {'save', 'learn', 'cancel', 'test', 'remove'}:
raise ControllerInvalid('Invalid controller operation')
return _request(payload, socket_path, timeout)
class _Job:
def __init__(self, payload):
self.payload = payload
self.deadline = time.monotonic() + REQUEST_DEADLINE
self.done = threading.Event()
self.cancelled = False
self.started = False
self.response = {'status': 'unavailable', 'result': 'Controller buttons are unavailable'}
class ControllerService:
"""The socket accepts configuration; physical button execution stays on the UI thread."""
def __init__(self, owner, socket_path=None):
self.owner = owner
self.path = Path(socket_path or DEFAULT_SOCKET_PATH)
self._lock = threading.Lock()
self._pending = None
self._closed = threading.Event()
self._listener = None
self._thread = None
self._identity = None
self._last_error = float('-inf')
def start(self):
if self._listener is not None or self._closed.is_set():
raise ControllerUnavailable('Controller service already started or closed')
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
parent = self.path.parent.lstat()
if parent.st_uid != os.getuid() or not stat.S_ISDIR(parent.st_mode) or parent.st_mode & 0o077:
raise ControllerUnavailable('Controller socket directory is not private')
try:
existing = self.path.lstat()
except FileNotFoundError:
pass
else:
if existing.st_uid != os.getuid() or not stat.S_ISSOCK(existing.st_mode):
raise ControllerUnavailable('Controller socket path is occupied')
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer:
peer.settimeout(0.1)
try:
peer.connect(str(self.path))
except (ConnectionRefusedError, FileNotFoundError):
self.path.unlink()
else:
raise ControllerUnavailable('Controller service is already active')
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
listener.bind(str(self.path))
info = self.path.lstat()
self._identity = info.st_dev, info.st_ino
os.chmod(self.path, 0o600)
listener.listen(8)
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='controller-settings', daemon=True)
self._thread.start()
def _serve(self, listener):
while not self._closed.is_set():
try:
peer, _ = listener.accept()
except TimeoutError:
continue
except OSError as error:
if error.errno in (errno.ECONNABORTED, errno.EINTR):
continue
break
with peer:
peer.settimeout(0.5)
try:
size = struct.unpack('!I', _receive(peer, 4))[0]
if not 0 < size <= MAX_BYTES:
raise ControllerInvalid('Invalid controller request size')
payload = _decode(_receive(peer, size))
if type(payload) is not dict or payload.get('operation') not in {'status', 'save', 'learn', 'cancel', 'test', 'remove'}:
raise ControllerInvalid('Invalid controller operation')
job = _Job(payload)
with self._lock:
self._pending = job
if not job.done.wait(REQUEST_DEADLINE):
with self._lock:
if not job.started:
job.cancelled = True
if self._pending is job:
self._pending = None
uncertain = job.started
job.response = {'status': 'unavailable', 'result': (
'Controller settings may have changed. Refresh before trying again.' if uncertain else
'Controller request expired before it ran. Refresh to try again.')}
encoded = _encode(job.response)
peer.sendall(struct.pack('!I', len(encoded)) + encoded)
except (ValueError, RecursionError) as error:
try:
encoded = _encode({'status': 'invalid', 'result': str(error)[:160]})
peer.sendall(struct.pack('!I', len(encoded)) + encoded)
except OSError:
pass
except (OSError, ControllerUnavailable):
pass
def poll(self):
with self._lock:
job = self._pending
self._pending = None
if job is None or job.cancelled or self._closed.is_set() or time.monotonic() >= job.deadline:
return
job.started = True
try:
payload = job.payload
if payload == {'operation': 'status'}:
result = self.owner.snapshot()
elif payload.get('operation') == 'status':
raise ControllerInvalid('Invalid controller status request')
else:
result = self.owner.action(payload)
job.response = {'status': 'ok', 'result': result}
_encode(job.response)
except PermissionError as error:
job.response = {'status': 'denied', 'result': str(error)[:160]}
except ValueError as error:
job.response = {'status': 'invalid', 'result': str(error)[:160]}
except (OSError, RuntimeError) as error:
job.response = {'status': 'unavailable', 'result': str(error)[:160]}
except Exception:
now = time.monotonic()
if now - self._last_error >= 10:
self._last_error = now
LOGGER.exception('Controller settings failed')
job.response = {'status': 'unavailable', 'result': 'Controller buttons are unavailable'}
finally:
job.done.set()
def close(self):
self._closed.set()
with self._lock:
if self._pending is not None:
self._pending.cancelled = True
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(REQUEST_DEADLINE + 0.2)
self._thread = None
self._unlink_owned()
def _unlink_owned(self):
identity, self._identity = self._identity, None
if identity is not None:
try:
info = self.path.lstat()
if (info.st_dev, info.st_ino) == identity:
self.path.unlink()
except FileNotFoundError:
pass