mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 09:03:46 +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>
257 lines
8.9 KiB
Python
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
|