mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(ipc): display control socket, stage 1: on-demand with acks
The display process now serves a Unix socket, /run/ledmatrix/control.sock, carrying versioned newline-delimited JSON commands that are acknowledged. Stage 1 moves on-demand start/stop (plus status, hello and ping) onto it; the cache-file mailbox stays as the fallback for one release. - src/ipc/contract.py: typed request/response envelopes, command args, error codes, NDJSON framing with a 64 KiB limit, socket path rules. - src/ipc/server.py: threaded server owned by the display. Handlers only queue onto a bounded queue and ack with the request id; the render thread drains it where it reads the mailbox. Bounded clients, timeouts, garbage/oversize/disconnect handling; 0660 socket in the cache dir's group plus SO_PEERCRED checks; skips cleanly on Windows or when off. - src/ipc/client.py: one short-timeout request; any failure raises ControlError(reason). - api_v3/display.py: on-demand start/stop try the socket, fall back to the mailbox exactly as before, and report transport/socket_error. - display_controller.py: start/close the server; the mailbox handler body is extracted into _handle_on_demand_request and shared by both paths. - docs/IPC_CONTROL_SOCKET.md: protocol, security model, stage plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+87
-18
@@ -42,6 +42,7 @@ from src.cache_manager import CacheManager
|
||||
from src.font_manager import FontManager
|
||||
from src.logging_config import get_logger
|
||||
from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
from src.ipc.server import ControlServer, start_control_server
|
||||
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
||||
|
||||
# Get logger with consistent configuration
|
||||
@@ -251,6 +252,10 @@ class DisplayController:
|
||||
# Monotonic stamp of the last _service_pending_changes pass; same
|
||||
# "None means never" convention as _last_on_demand_poll.
|
||||
self._last_pending_service: Optional[float] = None
|
||||
# The control socket (src/ipc), started by run(). None when it is not
|
||||
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
|
||||
# the file mailbox works either way.
|
||||
self._control_server = None
|
||||
# A brightness set_brightness() refused, so the periodic service pass
|
||||
# doesn't retry (and log) the same failure several times a second.
|
||||
self._failed_brightness_target: Optional[int] = None
|
||||
@@ -1313,10 +1318,12 @@ class DisplayController:
|
||||
|
||||
def _get_on_demand_remaining(self) -> Optional[float]:
|
||||
"""Calculate remaining time for an active on-demand session."""
|
||||
if not self.on_demand_active or self.on_demand_expires_at is None:
|
||||
# Read once: the control socket's status command calls this from
|
||||
# its own thread, while the render thread may be clearing the field.
|
||||
expires_at = self.on_demand_expires_at
|
||||
if not self.on_demand_active or expires_at is None:
|
||||
return None
|
||||
remaining = self.on_demand_expires_at - time.time()
|
||||
return max(0.0, remaining)
|
||||
return max(0.0, expires_at - time.time())
|
||||
|
||||
def _publish_current_mode_state(self) -> None:
|
||||
"""Publish the currently active display mode/plugin to cache for the web UI."""
|
||||
@@ -1350,23 +1357,27 @@ class DisplayController:
|
||||
or time.monotonic() - self._last_published_at >= CURRENT_STATE_REFRESH_SECONDS):
|
||||
self._publish_current_mode_state()
|
||||
|
||||
def _on_demand_state(self) -> Dict[str, Any]:
|
||||
"""The on-demand state as published to the cache and the control socket."""
|
||||
return {
|
||||
'active': self.on_demand_active,
|
||||
'mode': self.on_demand_mode,
|
||||
'plugin_id': self.on_demand_plugin_id,
|
||||
'requested_at': self.on_demand_requested_at,
|
||||
'expires_at': self.on_demand_expires_at,
|
||||
'duration': self.on_demand_duration,
|
||||
'pinned': self.on_demand_pinned,
|
||||
'status': self.on_demand_status,
|
||||
'error': self.on_demand_last_error,
|
||||
'last_event': self.on_demand_last_event,
|
||||
'remaining': self._get_on_demand_remaining(),
|
||||
'last_updated': time.time()
|
||||
}
|
||||
|
||||
def _publish_on_demand_state(self) -> None:
|
||||
"""Publish current on-demand state to cache for external consumers."""
|
||||
try:
|
||||
state = {
|
||||
'active': self.on_demand_active,
|
||||
'mode': self.on_demand_mode,
|
||||
'plugin_id': self.on_demand_plugin_id,
|
||||
'requested_at': self.on_demand_requested_at,
|
||||
'expires_at': self.on_demand_expires_at,
|
||||
'duration': self.on_demand_duration,
|
||||
'pinned': self.on_demand_pinned,
|
||||
'status': self.on_demand_status,
|
||||
'error': self.on_demand_last_error,
|
||||
'last_event': self.on_demand_last_event,
|
||||
'remaining': self._get_on_demand_remaining(),
|
||||
'last_updated': time.time()
|
||||
}
|
||||
state = self._on_demand_state()
|
||||
self.cache_manager.set('display_on_demand_state', state)
|
||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||
logger.error("Failed to publish on-demand state: %s", err, exc_info=True)
|
||||
@@ -1426,6 +1437,9 @@ class DisplayController:
|
||||
#: whole cost is one monotonic-clock compare.
|
||||
PENDING_CHANGES_INTERVAL = ON_DEMAND_POLL_INTERVAL
|
||||
|
||||
#: Class-level default for controllers built without __init__ (tests).
|
||||
_control_server: Optional[ControlServer] = None
|
||||
|
||||
def _service_pending_changes(self) -> None:
|
||||
"""Apply changes made elsewhere while the display thread is busy.
|
||||
|
||||
@@ -1446,7 +1460,10 @@ class DisplayController:
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = self._last_pending_service
|
||||
if last is not None and now - last < self.PENDING_CHANGES_INTERVAL:
|
||||
# A command queued on the control socket skips the floor: it is in
|
||||
# memory, so applying it now costs no disk read.
|
||||
if (last is not None and now - last < self.PENDING_CHANGES_INTERVAL
|
||||
and not (self._control_server and self._control_server.has_pending)):
|
||||
return
|
||||
self._last_pending_service = now
|
||||
|
||||
@@ -1588,8 +1605,49 @@ class DisplayController:
|
||||
except (OSError, AttributeError, KeyError) as err:
|
||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||
|
||||
def _start_control_server(self) -> None:
|
||||
"""Serve the control socket (src/ipc/server.py). Never raises.
|
||||
|
||||
Its handlers only queue commands; _drain_control_commands applies
|
||||
them on the render thread, where the mailbox is read.
|
||||
"""
|
||||
if self._control_server is not None:
|
||||
return
|
||||
try:
|
||||
self._control_server = start_control_server(
|
||||
status_provider=self._control_status,
|
||||
cache_dir=getattr(self.cache_manager, 'cache_dir', None))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket not started; using the file mailbox only")
|
||||
|
||||
def _control_status(self) -> Dict[str, Any]:
|
||||
"""The socket's on_demand.status answer. Runs on the socket's thread: reads only."""
|
||||
return {'on_demand': self._on_demand_state(),
|
||||
'current_mode': self.current_display_mode,
|
||||
'display_active': self.is_display_active}
|
||||
|
||||
def _drain_control_commands(self) -> None:
|
||||
"""Apply on-demand commands that arrived over the control socket.
|
||||
|
||||
Each goes through _handle_on_demand_request, the mailbox's own
|
||||
handler, so both ways in behave the same, and a request that came
|
||||
both ways (a client that timed out and fell back) has one request
|
||||
id and is processed once.
|
||||
"""
|
||||
server = self._control_server
|
||||
if server is None or not server.has_pending:
|
||||
return
|
||||
for command in server.drain():
|
||||
try:
|
||||
self._handle_on_demand_request(command.as_on_demand_request())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to apply control socket command %s",
|
||||
command.request_id)
|
||||
|
||||
def _poll_on_demand_requests(self) -> None:
|
||||
"""Poll cache for new on-demand requests from external controllers."""
|
||||
# Socket commands are already in memory: no disk read, so no floor.
|
||||
self._drain_control_commands()
|
||||
now = time.monotonic()
|
||||
if (self._last_on_demand_poll is not None
|
||||
and now - self._last_on_demand_poll < self.ON_DEMAND_POLL_INTERVAL):
|
||||
@@ -1614,7 +1672,10 @@ class DisplayController:
|
||||
|
||||
if not request:
|
||||
return
|
||||
self._handle_on_demand_request(request)
|
||||
|
||||
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
|
||||
"""Process one on-demand request, from the mailbox or the control socket."""
|
||||
request_id = request.get('request_id')
|
||||
if not request_id:
|
||||
return
|
||||
@@ -2248,6 +2309,7 @@ class DisplayController:
|
||||
# vouch for: beats from any other thread are ignored, so a render
|
||||
# thread stuck inside a plugin stops them.
|
||||
display_watchdog.watchdog.bind_render_thread()
|
||||
self._start_control_server()
|
||||
|
||||
try:
|
||||
# Initialize with cached data for fast startup - let background updates refresh naturally
|
||||
@@ -3644,6 +3706,13 @@ class DisplayController:
|
||||
# First: a clean stop is not a hang, and a heartbeat left behind
|
||||
# would read as a frozen panel to the web interface.
|
||||
display_watchdog.watchdog.stopping()
|
||||
# Stop taking commands; the socket file goes with it.
|
||||
if self._control_server is not None:
|
||||
try:
|
||||
self._control_server.close()
|
||||
except Exception as e:
|
||||
logger.warning("Error closing the control socket: %s", e)
|
||||
self._control_server = None
|
||||
# Stop the async update worker first so no in-flight update() call
|
||||
# is still touching display/cache-backed resources while they're
|
||||
# torn down below.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""The display process's control socket: web -> display commands with acks.
|
||||
|
||||
- :mod:`src.ipc.contract` -- the versioned messages, the framing and where
|
||||
the socket lives. Shared by both sides; standard library only.
|
||||
- :mod:`src.ipc.server` -- the display side: a threaded Unix-socket server
|
||||
whose handlers only queue work for the render thread.
|
||||
- :mod:`src.ipc.client` -- the web side: one short-timeout request.
|
||||
|
||||
See docs/IPC_CONTROL_SOCKET.md for the protocol, the security model and the
|
||||
stage plan.
|
||||
"""
|
||||
@@ -0,0 +1,200 @@
|
||||
"""The web side of the control socket: one request, a short timeout, no retries.
|
||||
|
||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
||||
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||
error the display returned -- raises :class:`ControlError` with a short
|
||||
``reason``, and the caller falls back to the file mailbox. Nothing here
|
||||
blocks for longer than ``timeout`` in total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from src.ipc.contract import (
|
||||
MAX_MESSAGE_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
SUPPORTED_VERSIONS,
|
||||
Command,
|
||||
FrameReader,
|
||||
ProtocolError,
|
||||
Request,
|
||||
Response,
|
||||
client_socket_paths,
|
||||
decode_message,
|
||||
encode_message,
|
||||
parse_args,
|
||||
socket_supported,
|
||||
)
|
||||
|
||||
#: Total budget for one request: connect, send and the reply. The display
|
||||
#: answers from a thread that does no rendering, normally within a few
|
||||
#: milliseconds; this only bounds a wedged one. The web route then falls back
|
||||
#: to the mailbox, so a timeout costs this much latency and nothing else.
|
||||
DEFAULT_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
class ControlError(Exception):
|
||||
"""The socket could not carry the request. ``reason`` is a short code.
|
||||
|
||||
Transport reasons: ``disabled``, ``unsupported``, ``no_socket``,
|
||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||
When the display answered with an error, ``reason`` is that error's
|
||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str = ''):
|
||||
super().__init__(reason, message)
|
||||
self.reason = reason
|
||||
self.message = message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
||||
|
||||
|
||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
request_id: Optional[str] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Send one command and return its ``result``. Raises :class:`ControlError`."""
|
||||
args = dict(args or {})
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
try:
|
||||
# Refuse locally what the display would refuse: a malformed id
|
||||
# (callers may pass their own) or arguments that break the contract.
|
||||
envelope = Request.from_dict({'v': PROTOCOL_VERSION, 'id': request_id,
|
||||
'cmd': cmd, 'args': args})
|
||||
parse_args(cmd, args)
|
||||
payload = encode_message(envelope.to_dict())
|
||||
except ProtocolError as e:
|
||||
raise ControlError('invalid_request', e.message) from None
|
||||
|
||||
if not socket_supported():
|
||||
raise ControlError('unsupported', 'no Unix sockets on this platform')
|
||||
candidates: List[str] = list(paths) if paths is not None else client_socket_paths()
|
||||
if not candidates:
|
||||
raise ControlError('disabled', 'the control socket is turned off')
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
sock = _connect(candidates, deadline)
|
||||
try:
|
||||
response = _exchange(sock, payload, deadline)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
# A refusal before the request was read (forbidden, too many
|
||||
# connections) carries no id.
|
||||
if response.id != request_id and not (response.id is None and not response.ok):
|
||||
raise ControlError('bad_response', 'the reply is for a different request')
|
||||
if not response.ok:
|
||||
error = response.error
|
||||
raise ControlError(error.code if error else 'bad_response',
|
||||
error.message if error else '')
|
||||
return dict(response.result or {})
|
||||
|
||||
|
||||
def _remaining(deadline: float) -> float:
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
raise ControlError('timeout', 'no reply in time')
|
||||
return left
|
||||
|
||||
|
||||
def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
||||
last = ControlError('no_socket', 'the display is not serving the control socket')
|
||||
for path in paths:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.connect(path)
|
||||
return sock
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
sock.close()
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'nothing is listening at {path}')
|
||||
except BlockingIOError:
|
||||
# EAGAIN: the listen backlog is full -- a live but swamped display.
|
||||
sock.close()
|
||||
raise ControlError('busy', 'the display is not accepting connections') from None
|
||||
except PermissionError:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'no permission to connect to {path}')
|
||||
except socket.timeout:
|
||||
sock.close()
|
||||
raise ControlError('timeout', 'connect timed out') from None
|
||||
except ControlError:
|
||||
sock.close()
|
||||
raise
|
||||
except OSError as e:
|
||||
sock.close()
|
||||
last = ControlError('refused', f'{path}: {e}')
|
||||
raise last
|
||||
|
||||
|
||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.sendall(payload)
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
while True:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
raise ControlError('closed', 'the display closed the connection')
|
||||
lines = reader.feed(data)
|
||||
if lines:
|
||||
return Response.from_dict(decode_message(lines[0]))
|
||||
except socket.timeout:
|
||||
raise ControlError('timeout', 'no reply in time') from None
|
||||
except ProtocolError as e:
|
||||
raise ControlError('bad_response', e.message) from None
|
||||
except ControlError:
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ControlError('closed', str(e)) from None
|
||||
|
||||
|
||||
# -- commands ---------------------------------------------------------------------------
|
||||
|
||||
def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[str],
|
||||
duration: Any = None, pinned: bool = False, *,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
|
||||
|
||||
``request_id`` doubles as the on-demand request id, so a request that a
|
||||
timed-out caller then also writes to the mailbox is processed only once.
|
||||
"""
|
||||
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
||||
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def on_demand_stop(request_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to end on-demand. Returns the ack; raises :class:`ControlError`."""
|
||||
return request(Command.ON_DEMAND_STOP, {}, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""The display's live on-demand state. Raises :class:`ControlError`."""
|
||||
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
||||
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
|
||||
timeout=timeout, paths=paths)
|
||||
@@ -0,0 +1,527 @@
|
||||
"""The control socket's contract: versioned messages, framing and location.
|
||||
|
||||
Both processes import this module -- the display serves the socket
|
||||
(:mod:`src.ipc.server`) and the web interface calls it
|
||||
(:mod:`src.ipc.client`) -- so it is the one definition of what goes over the
|
||||
wire. Standard library only, and no import of the rest of ``src``.
|
||||
|
||||
Wire format (protocol version 1)
|
||||
--------------------------------
|
||||
One JSON object per line (newline-delimited JSON), UTF-8, at most
|
||||
:data:`MAX_MESSAGE_BYTES` per line including the newline. Messages are
|
||||
encoded with ``ensure_ascii``, so a newline never appears inside one.
|
||||
|
||||
Request::
|
||||
|
||||
{"v": 1, "id": "<1-128 chars>", "cmd": "on_demand.start", "args": {...}}
|
||||
|
||||
Response, always carrying the request's ``id`` (``null`` when the request
|
||||
could not be parsed far enough to have one)::
|
||||
|
||||
{"v": 1, "id": "...", "ok": true, "result": {...}}
|
||||
{"v": 1, "id": "...", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||
|
||||
A connection may carry several requests; each gets exactly one response, in
|
||||
order. Commands that change what the panel shows are *acknowledged*, not
|
||||
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
|
||||
has the command queued and will apply it at its next on-demand check. Its
|
||||
outcome is published the way it always was (``display_on_demand_state``,
|
||||
later the state stream).
|
||||
|
||||
See docs/IPC_CONTROL_SOCKET.md for the full description.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypedDict, TypeGuard, Union
|
||||
|
||||
|
||||
# -- versions and limits -------------------------------------------------------
|
||||
|
||||
#: The protocol version this code speaks by default.
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
#: Every version this code can speak; ``hello`` picks the highest common one.
|
||||
SUPPORTED_VERSIONS: Tuple[int, ...] = (1,)
|
||||
|
||||
#: The largest message either side sends or accepts, newline included. A
|
||||
#: stage-1 message is well under 1 KiB; this only bounds a broken or hostile
|
||||
#: peer, so a reader never buffers more than this per connection.
|
||||
MAX_MESSAGE_BYTES = 64 * 1024
|
||||
|
||||
#: Longest request id. Ids are also the on-demand ``request_id``, which the
|
||||
#: display logs and stores, so they are kept short.
|
||||
MAX_ID_LENGTH = 128
|
||||
|
||||
#: Longest plugin id or mode name an on-demand command may carry.
|
||||
MAX_NAME_LENGTH = 128
|
||||
|
||||
|
||||
# -- where the socket lives ------------------------------------------------------
|
||||
|
||||
#: ``RuntimeDirectory=ledmatrix`` in ledmatrix.service creates this (tmpfs,
|
||||
#: root-owned, 0755); a display under an older unit creates it itself, as it
|
||||
#: does for the heartbeat (src/display_watchdog.py).
|
||||
DEFAULT_SOCKET_DIR = '/run/ledmatrix'
|
||||
SOCKET_NAME = 'control.sock'
|
||||
DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
|
||||
|
||||
#: Overrides the socket path for both processes (a dev checkout, a second
|
||||
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
||||
#: display does not serve it and the web interface goes straight to the
|
||||
#: file mailbox.
|
||||
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
||||
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
|
||||
|
||||
|
||||
def socket_supported() -> bool:
|
||||
"""Whether this platform has Unix sockets at all (Windows Python does not)."""
|
||||
import socket
|
||||
return os.name == 'posix' and hasattr(socket, 'AF_UNIX')
|
||||
|
||||
|
||||
def socket_disabled(environ: Optional[Mapping[str, str]] = None) -> bool:
|
||||
"""True when :data:`SOCKET_PATH_ENV` switches the socket off."""
|
||||
env = os.environ if environ is None else environ
|
||||
value = (env.get(SOCKET_PATH_ENV) or '').strip()
|
||||
return value.lower() in DISABLED_VALUES
|
||||
|
||||
|
||||
def configured_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
||||
"""The path :data:`SOCKET_PATH_ENV` names, or None when it is unset or 'off'."""
|
||||
env = os.environ if environ is None else environ
|
||||
value = (env.get(SOCKET_PATH_ENV) or '').strip()
|
||||
if not value or value.lower() in DISABLED_VALUES:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def dev_socket_path(uid: Optional[int] = None) -> str:
|
||||
"""Where a display that cannot use /run/ledmatrix serves the socket.
|
||||
|
||||
A per-user directory under the temp dir, so a dev checkout run as an
|
||||
ordinary user (``python3 run.py -e``) and its web interface, run by the
|
||||
same user, find each other with no configuration.
|
||||
"""
|
||||
if uid is None:
|
||||
getuid = getattr(os, 'getuid', None)
|
||||
uid = getuid() if getuid is not None else 0
|
||||
return os.path.join(tempfile.gettempdir(), f'ledmatrix-{uid}', SOCKET_NAME)
|
||||
|
||||
|
||||
def client_socket_paths(environ: Optional[Mapping[str, str]] = None) -> List[str]:
|
||||
"""The paths a client tries, in order; empty when the socket is off."""
|
||||
if socket_disabled(environ):
|
||||
return []
|
||||
configured = configured_socket_path(environ)
|
||||
if configured:
|
||||
return [configured]
|
||||
return [DEFAULT_SOCKET_PATH, dev_socket_path()]
|
||||
|
||||
|
||||
# -- commands and error codes ----------------------------------------------------
|
||||
|
||||
class Command:
|
||||
"""Command names. Dotted names group a feature's commands."""
|
||||
HELLO = 'hello'
|
||||
PING = 'ping'
|
||||
ON_DEMAND_START = 'on_demand.start'
|
||||
ON_DEMAND_STOP = 'on_demand.stop'
|
||||
ON_DEMAND_STATUS = 'on_demand.status'
|
||||
|
||||
|
||||
#: Every command version 1 defines, in the order ``hello`` reports them.
|
||||
COMMANDS: Tuple[str, ...] = (
|
||||
Command.HELLO,
|
||||
Command.PING,
|
||||
Command.ON_DEMAND_START,
|
||||
Command.ON_DEMAND_STOP,
|
||||
Command.ON_DEMAND_STATUS,
|
||||
)
|
||||
|
||||
#: Commands that are queued for the render thread and answered with an ack.
|
||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP})
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
"""``error.code`` values. Clients branch on these, never on the message."""
|
||||
BAD_JSON = 'bad_json' # a line that is not a JSON object
|
||||
BAD_REQUEST = 'bad_request' # the envelope is malformed
|
||||
MESSAGE_TOO_LARGE = 'message_too_large' # over MAX_MESSAGE_BYTES
|
||||
UNSUPPORTED_VERSION = 'unsupported_version' # no version in common
|
||||
UNKNOWN_COMMAND = 'unknown_command'
|
||||
INVALID_ARGS = 'invalid_args'
|
||||
BUSY = 'busy' # queue full / too many clients
|
||||
FORBIDDEN = 'forbidden' # peer credentials refused
|
||||
INTERNAL = 'internal' # a bug on the display side
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
"""A message that breaks the contract. ``code`` is an :class:`ErrorCode`."""
|
||||
|
||||
def __init__(self, code: str, message: str, request_id: Optional[str] = None):
|
||||
super().__init__(code, message, request_id)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.request_id = request_id
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.code}: {self.message}'
|
||||
|
||||
|
||||
# -- the envelope ------------------------------------------------------------------
|
||||
|
||||
def _is_int(value: Any) -> TypeGuard[int]:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _valid_id(value: Any) -> bool:
|
||||
return (isinstance(value, str) and 0 < len(value) <= MAX_ID_LENGTH
|
||||
and value.isprintable())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Request:
|
||||
"""``{v, id, cmd, args}``."""
|
||||
id: str
|
||||
cmd: str
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'v': self.v, 'id': self.id, 'cmd': self.cmd, 'args': dict(self.args)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Any) -> 'Request':
|
||||
"""Validate an envelope. Raises :class:`ProtocolError`.
|
||||
|
||||
The version is checked by the server, not here, so that ``hello``
|
||||
can negotiate across versions.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a request must be a JSON object')
|
||||
raw_id = obj.get('id')
|
||||
request_id = raw_id if _valid_id(raw_id) else None
|
||||
if request_id is None:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST,
|
||||
f'id must be a printable string of 1-{MAX_ID_LENGTH} characters')
|
||||
version = obj.get('v')
|
||||
if not _is_int(version):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer', request_id)
|
||||
cmd = obj.get('cmd')
|
||||
if not isinstance(cmd, str) or not cmd:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'cmd must be a non-empty string', request_id)
|
||||
args = obj.get('args', {})
|
||||
if args is None:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'args must be a JSON object', request_id)
|
||||
return cls(id=request_id, cmd=cmd, args=args, v=version)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorInfo:
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {'code': self.code, 'message': self.message}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
"""``{v, id, ok, result}`` or ``{v, id, ok: false, error: {code, message}}``."""
|
||||
id: Optional[str]
|
||||
ok: bool
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[ErrorInfo] = None
|
||||
v: int = PROTOCOL_VERSION
|
||||
|
||||
@classmethod
|
||||
def success(cls, request_id: Optional[str], result: Mapping[str, Any],
|
||||
v: int = PROTOCOL_VERSION) -> 'Response':
|
||||
return cls(id=request_id, ok=True, result=dict(result), v=v)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, request_id: Optional[str], code: str, message: str,
|
||||
v: int = PROTOCOL_VERSION) -> 'Response':
|
||||
return cls(id=request_id, ok=False, error=ErrorInfo(code, message), v=v)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {'v': self.v, 'id': self.id, 'ok': self.ok}
|
||||
if self.ok:
|
||||
out['result'] = dict(self.result or {})
|
||||
else:
|
||||
error = self.error or ErrorInfo(ErrorCode.INTERNAL, 'unknown error')
|
||||
out['error'] = error.to_dict()
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Any) -> 'Response':
|
||||
"""Validate a response. Raises :class:`ProtocolError` (BAD_REQUEST)."""
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a response must be a JSON object')
|
||||
version = obj.get('v')
|
||||
if not _is_int(version):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
|
||||
raw_id = obj.get('id')
|
||||
if raw_id is not None and not isinstance(raw_id, str):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string or null')
|
||||
ok = obj.get('ok')
|
||||
if not isinstance(ok, bool):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'ok must be a boolean')
|
||||
if ok:
|
||||
result = obj.get('result', {})
|
||||
if not isinstance(result, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
|
||||
return cls(id=raw_id, ok=True, result=result, v=version)
|
||||
error = obj.get('error')
|
||||
if (not isinstance(error, dict) or not isinstance(error.get('code'), str)
|
||||
or not isinstance(error.get('message', ''), str)):
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, 'error must be {code, message}')
|
||||
return cls(id=raw_id, ok=False,
|
||||
error=ErrorInfo(error['code'], error.get('message', '')), v=version)
|
||||
|
||||
|
||||
# -- command arguments -------------------------------------------------------------
|
||||
|
||||
def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
|
||||
value = args.get(key)
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > MAX_NAME_LENGTH or not value.isprintable():
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
f'{key} must be a printable string of at most '
|
||||
f'{MAX_NAME_LENGTH} characters')
|
||||
return value
|
||||
|
||||
|
||||
def _optional_duration(value: Any) -> Optional[float]:
|
||||
"""Seconds, or None for "until stopped". 0 means the same as None.
|
||||
|
||||
Numbers and numeric strings are accepted, the same as the REST route and
|
||||
the file mailbox take them; anything else is refused rather than guessed.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'duration must be a number of seconds')
|
||||
try:
|
||||
seconds = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'duration must be a number of seconds') from None
|
||||
if not math.isfinite(seconds) or seconds < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'duration must be a finite, non-negative number of seconds')
|
||||
return seconds or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HelloArgs:
|
||||
"""``hello``: the versions the client speaks, and a name for the logs."""
|
||||
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
||||
client: str = ''
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'versions': list(self.versions), 'client': self.client}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
||||
versions = args.get('versions', [PROTOCOL_VERSION])
|
||||
if (not isinstance(versions, list) or not versions or len(versions) > 32
|
||||
or not all(_is_int(v) for v in versions)):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'versions must be a list of integers')
|
||||
client = args.get('client', '')
|
||||
if not isinstance(client, str) or len(client) > MAX_NAME_LENGTH:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'client must be a short string')
|
||||
return cls(versions=tuple(versions), client=client)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnDemandStartArgs:
|
||||
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
||||
|
||||
The same fields the file mailbox carries. At least one of ``plugin_id``
|
||||
and ``mode`` is required; the display resolves the other.
|
||||
"""
|
||||
plugin_id: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
pinned: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'plugin_id': self.plugin_id, 'mode': self.mode,
|
||||
'duration': self.duration, 'pinned': self.pinned}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
||||
plugin_id = _optional_name(args, 'plugin_id')
|
||||
mode = _optional_name(args, 'mode')
|
||||
if plugin_id is None and mode is None:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id or mode is required')
|
||||
pinned = args.get('pinned', False)
|
||||
if pinned is None:
|
||||
pinned = False
|
||||
if not isinstance(pinned, bool):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'pinned must be a boolean')
|
||||
return cls(plugin_id=plugin_id, mode=mode,
|
||||
duration=_optional_duration(args.get('duration')), pinned=pinned)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnDemandStopArgs:
|
||||
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoArgs:
|
||||
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
||||
return cls()
|
||||
|
||||
|
||||
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs]
|
||||
|
||||
_ARG_TYPES: Dict[str, Any] = {
|
||||
Command.HELLO: HelloArgs,
|
||||
Command.PING: NoArgs,
|
||||
Command.ON_DEMAND_START: OnDemandStartArgs,
|
||||
Command.ON_DEMAND_STOP: OnDemandStopArgs,
|
||||
Command.ON_DEMAND_STATUS: NoArgs,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
|
||||
"""Typed arguments for ``cmd``. Raises :class:`ProtocolError`."""
|
||||
arg_type = _ARG_TYPES.get(cmd)
|
||||
if arg_type is None:
|
||||
raise ProtocolError(ErrorCode.UNKNOWN_COMMAND, f'unknown command: {cmd[:64]}')
|
||||
parsed: CommandArgs = arg_type.from_dict(args)
|
||||
return parsed
|
||||
|
||||
|
||||
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
||||
timestamp: float) -> Dict[str, Any]:
|
||||
"""The file-mailbox payload for a queued on-demand command.
|
||||
|
||||
The display hands socket commands to the same code that handles the
|
||||
mailbox (``DisplayController._handle_on_demand_request``), so a command
|
||||
behaves identically whichever way it arrived, and a request that came
|
||||
both ways (a client that timed out and fell back) is processed once: the
|
||||
request id is the same.
|
||||
"""
|
||||
if isinstance(args, OnDemandStartArgs):
|
||||
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
||||
'mode': args.mode, 'duration': args.duration, 'pinned': args.pinned,
|
||||
'timestamp': timestamp, 'source': 'socket'}
|
||||
return {'request_id': request_id, 'action': 'stop', 'timestamp': timestamp,
|
||||
'source': 'socket'}
|
||||
|
||||
|
||||
# -- results -----------------------------------------------------------------------
|
||||
|
||||
class HelloResult(TypedDict):
|
||||
version: int
|
||||
versions: List[int]
|
||||
commands: List[str]
|
||||
max_message_bytes: int
|
||||
server: str
|
||||
|
||||
|
||||
class PingResult(TypedDict):
|
||||
pong: bool
|
||||
|
||||
|
||||
class AckResult(TypedDict):
|
||||
"""The answer to a queued command: the render thread will apply it."""
|
||||
accepted: bool
|
||||
request_id: str
|
||||
queued: int
|
||||
|
||||
|
||||
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
|
||||
"""The highest version both sides speak, or None."""
|
||||
common = set(client_versions) & set(SUPPORTED_VERSIONS)
|
||||
return max(common) if common else None
|
||||
|
||||
|
||||
# -- framing -----------------------------------------------------------------------
|
||||
|
||||
def encode_message(obj: Mapping[str, Any]) -> bytes:
|
||||
"""One newline-terminated JSON line. Raises :class:`ProtocolError` when too big."""
|
||||
try:
|
||||
text = json.dumps(obj, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ProtocolError(ErrorCode.BAD_REQUEST, f'message is not JSON-serialisable: {e}') from None
|
||||
data = text.encode('ascii') + b'\n'
|
||||
if len(data) > MAX_MESSAGE_BYTES:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message is {len(data)} bytes; the limit is {MAX_MESSAGE_BYTES}')
|
||||
return data
|
||||
|
||||
|
||||
def decode_message(line: bytes) -> Dict[str, Any]:
|
||||
"""Parse one line (newline optional). Raises :class:`ProtocolError` (BAD_JSON)."""
|
||||
try:
|
||||
obj = json.loads(line.decode('utf-8'))
|
||||
except ValueError: # UnicodeDecodeError and JSONDecodeError are both ValueErrors
|
||||
raise ProtocolError(ErrorCode.BAD_JSON, 'not valid UTF-8 JSON') from None
|
||||
if not isinstance(obj, dict):
|
||||
raise ProtocolError(ErrorCode.BAD_JSON, 'a message must be a JSON object')
|
||||
return obj
|
||||
|
||||
|
||||
class FrameReader:
|
||||
"""Splits a byte stream into lines, never holding more than one message.
|
||||
|
||||
``feed()`` returns the complete lines (without their newlines) the new
|
||||
bytes finished, and raises :class:`ProtocolError` (MESSAGE_TOO_LARGE) as
|
||||
soon as a line is longer than the limit, newline or not, so a peer that
|
||||
never sends one cannot make the reader buffer without bound.
|
||||
"""
|
||||
|
||||
def __init__(self, max_bytes: int = MAX_MESSAGE_BYTES):
|
||||
self._max = max_bytes
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
"""Bytes of an unfinished message held."""
|
||||
return len(self._buffer)
|
||||
|
||||
def feed(self, data: bytes) -> List[bytes]:
|
||||
self._buffer.extend(data)
|
||||
lines: List[bytes] = []
|
||||
while True:
|
||||
newline = self._buffer.find(b'\n')
|
||||
if newline < 0:
|
||||
break
|
||||
if newline + 1 > self._max:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message exceeds {self._max} bytes')
|
||||
line = bytes(self._buffer[:newline])
|
||||
del self._buffer[:newline + 1]
|
||||
if line.strip():
|
||||
lines.append(line)
|
||||
if len(self._buffer) >= self._max:
|
||||
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
|
||||
f'message exceeds {self._max} bytes')
|
||||
return lines
|
||||
@@ -0,0 +1,643 @@
|
||||
"""The display side of the control socket.
|
||||
|
||||
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
|
||||
by default; see :mod:`src.ipc.contract` for the protocol). It never touches
|
||||
rendering: a command that changes the panel is validated, put on a bounded
|
||||
queue and acknowledged, and the render thread drains that queue at the point
|
||||
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||
handing each command to the same code. Queries (``on_demand.status``) are
|
||||
answered from a snapshot callable the display provides.
|
||||
|
||||
Robustness rules, because this runs inside the display process:
|
||||
|
||||
* every connection has its own daemon thread, at most :data:`MAX_CLIENTS` at
|
||||
once; one more is told ``busy`` and closed;
|
||||
* every read and write has a timeout, a message must arrive whole within
|
||||
:data:`MESSAGE_TIMEOUT_SECONDS`, and an idle connection is closed after
|
||||
:data:`IDLE_TIMEOUT_SECONDS` -- a slow or stuck client costs one thread for
|
||||
a few seconds, never the render loop;
|
||||
* a line that is not JSON is answered with ``bad_json`` and the connection
|
||||
carries on; a line over the size limit closes the connection; a client
|
||||
that disconnects mid-message is simply dropped;
|
||||
* no exception from a handler leaves the connection thread.
|
||||
|
||||
Who may connect (see docs/IPC_CONTROL_SOCKET.md, "Security model"): the
|
||||
socket file is ``0660`` and group-owned by the group the display and the web
|
||||
interface share -- the cache directory's group, the same rule DiskCache uses
|
||||
for the files it shares -- so the kernel refuses everyone else at connect().
|
||||
Where the kernel reports the peer's credentials (``SO_PEERCRED``, Linux) the
|
||||
server checks them again: root, its own user, or a member of that group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Union
|
||||
|
||||
from src.ipc.contract import (
|
||||
COMMANDS,
|
||||
DEFAULT_SOCKET_DIR,
|
||||
DEFAULT_SOCKET_PATH,
|
||||
MAX_MESSAGE_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
QUEUED_COMMANDS,
|
||||
SUPPORTED_VERSIONS,
|
||||
AckResult,
|
||||
Command,
|
||||
ErrorCode,
|
||||
FrameReader,
|
||||
HelloArgs,
|
||||
HelloResult,
|
||||
OnDemandStartArgs,
|
||||
OnDemandStopArgs,
|
||||
ProtocolError,
|
||||
Request,
|
||||
Response,
|
||||
configured_socket_path,
|
||||
decode_message,
|
||||
dev_socket_path,
|
||||
encode_message,
|
||||
negotiate_version,
|
||||
on_demand_request,
|
||||
parse_args,
|
||||
socket_disabled,
|
||||
socket_supported,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Concurrent connections served. The web interface opens one per request
|
||||
#: and closes it; this only bounds a misbehaving client.
|
||||
MAX_CLIENTS = 8
|
||||
|
||||
#: Commands waiting for the render thread. It drains them at least every
|
||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
|
||||
QUEUE_SIZE = 16
|
||||
|
||||
#: Timeout for one recv()/send() on a connection.
|
||||
IO_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
#: A message must arrive whole within this long of its first byte.
|
||||
MESSAGE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
#: A connection with no message in progress is closed after this long.
|
||||
IDLE_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
#: How often the accept loop wakes to notice close().
|
||||
_ACCEPT_POLL_SECONDS = 0.5
|
||||
|
||||
_LISTEN_BACKLOG = 64
|
||||
|
||||
|
||||
# -- queued work ---------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedCommand:
|
||||
"""A command waiting for the render thread."""
|
||||
request_id: str
|
||||
cmd: str
|
||||
args: Union[OnDemandStartArgs, OnDemandStopArgs]
|
||||
received_at: float # time.time() when it was accepted
|
||||
peer_uid: Optional[int] = None
|
||||
|
||||
def as_on_demand_request(self) -> Dict[str, Any]:
|
||||
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
||||
return on_demand_request(self.request_id, self.args, self.received_at)
|
||||
|
||||
|
||||
# -- peer credentials ------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerCredentials:
|
||||
pid: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
def peer_credentials(conn: socket.socket) -> Optional[PeerCredentials]:
|
||||
"""The connecting process's pid/uid/gid, where the kernel reports them.
|
||||
|
||||
``SO_PEERCRED`` is Linux's; elsewhere this is None and the socket file's
|
||||
mode is the only gate.
|
||||
"""
|
||||
option = getattr(socket, 'SO_PEERCRED', None)
|
||||
if option is None:
|
||||
return None
|
||||
try:
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, option, struct.calcsize('3i'))
|
||||
pid, uid, gid = struct.unpack('3i', raw)
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
return PeerCredentials(pid=pid, uid=uid, gid=gid)
|
||||
|
||||
|
||||
def process_groups(pid: int) -> Optional[FrozenSet[int]]:
|
||||
"""A process's supplementary groups, from /proc; None when unreadable.
|
||||
|
||||
The web service's primary group is normally its user's own; the shared
|
||||
group is a supplementary one, which ``SO_PEERCRED`` does not report.
|
||||
"""
|
||||
try:
|
||||
with open(f'/proc/{int(pid)}/status', 'r', encoding='ascii', errors='replace') as f:
|
||||
for line in f:
|
||||
if line.startswith('Groups:'):
|
||||
return frozenset(int(g) for g in line.split()[1:] if g.isdigit())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return frozenset()
|
||||
|
||||
|
||||
def user_in_group(uid: int, gid: int) -> bool:
|
||||
"""Whether the account ``uid`` is listed in group ``gid`` (the group database)."""
|
||||
try:
|
||||
import grp
|
||||
import pwd
|
||||
name = pwd.getpwuid(uid).pw_name
|
||||
group = grp.getgrgid(gid)
|
||||
except (ImportError, KeyError, OSError):
|
||||
return False
|
||||
return name in group.gr_mem or pwd.getpwuid(uid).pw_gid == gid
|
||||
|
||||
|
||||
def peer_allowed(cred: PeerCredentials, own_uid: int, allowed_gid: Optional[int],
|
||||
groups: Optional[FrozenSet[int]] = None,
|
||||
in_group: Callable[[int, int], bool] = user_in_group) -> bool:
|
||||
"""The permission model: root, the server's own user, or the shared group.
|
||||
|
||||
``groups`` are the peer's supplementary groups (from /proc); when they
|
||||
could not be read the group database decides instead.
|
||||
"""
|
||||
if cred.uid == 0 or cred.uid == own_uid:
|
||||
return True
|
||||
if allowed_gid is None:
|
||||
return False
|
||||
if cred.gid == allowed_gid:
|
||||
return True
|
||||
if groups is not None:
|
||||
return allowed_gid in groups
|
||||
return in_group(cred.uid, allowed_gid)
|
||||
|
||||
|
||||
def resolve_socket_group(cache_dir: Optional[str]) -> Optional[int]:
|
||||
"""The group the socket should belong to: the one the two services share.
|
||||
|
||||
The cache directory's group when the directory is group-writable --
|
||||
the rule DiskCache applies to every file the display shares with the web
|
||||
interface (``root:ledmatrix 2775`` on an installed device). Otherwise the
|
||||
project directory's group (``get_shared_group_gid``), which config files
|
||||
use. None when neither is known: then only root and the display's own
|
||||
user can connect.
|
||||
"""
|
||||
if cache_dir:
|
||||
try:
|
||||
st = os.stat(cache_dir)
|
||||
if st.st_mode & stat.S_IWGRP:
|
||||
return st.st_gid
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
from src.common.permission_utils import get_shared_group_gid
|
||||
return get_shared_group_gid()
|
||||
except ImportError: # pragma: no cover - src is always importable here
|
||||
return None
|
||||
|
||||
|
||||
def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
||||
"""Where the display should serve the socket; None when it should not.
|
||||
|
||||
:data:`~src.ipc.contract.SOCKET_PATH_ENV` wins. Otherwise
|
||||
/run/ledmatrix/control.sock when the display can create or write that
|
||||
directory (root, which an installed display always is), and the per-user
|
||||
dev path otherwise (an emulator run from a checkout).
|
||||
"""
|
||||
if not socket_supported() or socket_disabled(environ):
|
||||
return None
|
||||
configured = configured_socket_path(environ)
|
||||
if configured:
|
||||
return configured
|
||||
geteuid = getattr(os, 'geteuid', None)
|
||||
if (geteuid is not None and geteuid() == 0) or os.access(DEFAULT_SOCKET_DIR, os.W_OK):
|
||||
return DEFAULT_SOCKET_PATH
|
||||
return dev_socket_path()
|
||||
|
||||
|
||||
# -- the server ------------------------------------------------------------------------
|
||||
|
||||
StatusProvider = Callable[[], Dict[str, Any]]
|
||||
|
||||
|
||||
class ControlServer:
|
||||
"""Serves the control socket on background threads.
|
||||
|
||||
``start()`` binds and starts accepting; ``drain()`` (render thread) takes
|
||||
the queued commands; ``close()`` stops and removes the socket file.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, status_provider: Optional[StatusProvider] = None,
|
||||
group: Optional[int] = None, *, queue_size: int = QUEUE_SIZE,
|
||||
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
|
||||
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
|
||||
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
|
||||
check_peer: bool = True):
|
||||
self.path = path
|
||||
self._status_provider = status_provider
|
||||
self._group = group
|
||||
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
|
||||
self._pending = threading.Event()
|
||||
self._slots = threading.BoundedSemaphore(max_clients)
|
||||
self._io_timeout = io_timeout
|
||||
self._message_timeout = message_timeout
|
||||
self._idle_timeout = idle_timeout
|
||||
self._check_peer = check_peer
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._identity: Optional[tuple] = None # (st_dev, st_ino) of our socket file
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stopping = threading.Event()
|
||||
self._own_uid = os.geteuid() if hasattr(os, 'geteuid') else -1
|
||||
|
||||
# -- lifecycle -------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def socket_mode(self) -> int:
|
||||
"""0660 with a shared group; 0600 (the display's user only) without one."""
|
||||
return 0o660 if self._group is not None else 0o600
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Bind and start serving. False (logged) when the socket cannot be served.
|
||||
|
||||
Never raises: without the socket the web interface uses the file
|
||||
mailbox, exactly as before.
|
||||
"""
|
||||
if not socket_supported():
|
||||
logger.debug("Control socket not started: no Unix sockets on this platform")
|
||||
return False
|
||||
try:
|
||||
self._prepare_directory()
|
||||
if not self._clear_stale_socket():
|
||||
return False
|
||||
self._bind()
|
||||
except OSError as e:
|
||||
logger.warning("Control socket not started at %s (%s); the web interface "
|
||||
"will use the file mailbox", self.path, e)
|
||||
self._close_socket()
|
||||
return False
|
||||
self._stopping.clear()
|
||||
self._thread = threading.Thread(target=self._accept_loop, name='ledmatrix-ipc',
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Control socket listening at %s (mode %o, group %s)",
|
||||
self.path, self.socket_mode,
|
||||
self._group if self._group is not None else 'none')
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop accepting and remove the socket file (only if it is still ours)."""
|
||||
self._stopping.set()
|
||||
self._close_socket()
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
if self._identity is not None:
|
||||
try:
|
||||
st = os.lstat(self.path)
|
||||
if (st.st_dev, st.st_ino) == self._identity:
|
||||
os.unlink(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
self._identity = None
|
||||
|
||||
def _close_socket(self) -> None:
|
||||
sock, self._sock = self._sock, None
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _prepare_directory(self) -> None:
|
||||
directory = os.path.dirname(os.path.abspath(self.path))
|
||||
if self.path == dev_socket_path():
|
||||
# The dev path is in the shared temp dir: private to this user,
|
||||
# and refused if someone else got there first.
|
||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
||||
self._check_private_directory(directory)
|
||||
elif not os.path.isdir(directory):
|
||||
# /run/ledmatrix under a unit that predates RuntimeDirectory= (the
|
||||
# display is root and makes it, as it does for the heartbeat), or
|
||||
# a configured path. 0755: the web interface only needs to reach
|
||||
# the socket; the socket's own mode decides who may connect.
|
||||
os.makedirs(directory, mode=0o755, exist_ok=True)
|
||||
|
||||
def _check_private_directory(self, directory: str) -> None:
|
||||
"""Refuse a dev directory someone else made (it lives in a shared /tmp)."""
|
||||
st = os.lstat(directory)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
raise OSError(f'{directory} is not a plain directory')
|
||||
if hasattr(os, 'geteuid') and st.st_uid != os.geteuid():
|
||||
raise OSError(f'{directory} belongs to uid {st.st_uid}, not this user')
|
||||
|
||||
def _clear_stale_socket(self) -> bool:
|
||||
"""Remove a socket left by a display that died; never a live or foreign file."""
|
||||
try:
|
||||
st = os.lstat(self.path)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
if not stat.S_ISSOCK(st.st_mode):
|
||||
logger.error("Control socket not started: %s exists and is not a socket", self.path)
|
||||
return False
|
||||
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
probe.settimeout(0.5)
|
||||
try:
|
||||
probe.connect(self.path)
|
||||
except OSError:
|
||||
os.unlink(self.path) # nothing listening: a previous display's leftover
|
||||
return True
|
||||
finally:
|
||||
probe.close()
|
||||
logger.warning("Control socket not started: another process is serving %s", self.path)
|
||||
return False
|
||||
|
||||
def _bind(self) -> None:
|
||||
"""Bind under a temporary name, set mode and group, then rename into place.
|
||||
|
||||
The rename makes the socket appear with its final permissions, never
|
||||
briefly with the process umask's.
|
||||
"""
|
||||
tmp = f'{self.path}.{os.getpid()}.tmp'
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._sock = sock
|
||||
try:
|
||||
sock.bind(tmp)
|
||||
os.chmod(tmp, self.socket_mode)
|
||||
if self._group is not None and hasattr(os, 'chown'):
|
||||
try:
|
||||
os.chown(tmp, -1, self._group)
|
||||
except OSError as e:
|
||||
# Not root and not in the group (a dev run): only this user
|
||||
# (and root) can connect, which is what a dev run needs.
|
||||
logger.debug("Could not give the control socket group %s: %s",
|
||||
self._group, e)
|
||||
# The backlog is only the kernel's queue in front of accept();
|
||||
# MAX_CLIENTS still bounds what is served. A short one makes a
|
||||
# burst of clients fail connect() with EAGAIN instead of being
|
||||
# answered (busy or otherwise).
|
||||
sock.listen(_LISTEN_BACKLOG)
|
||||
sock.settimeout(_ACCEPT_POLL_SECONDS)
|
||||
os.rename(tmp, self.path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
st = os.lstat(self.path)
|
||||
self._identity = (st.st_dev, st.st_ino)
|
||||
|
||||
# -- the render thread's side ------------------------------------------------
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""Cheap check for queued commands, for the render thread's fast path."""
|
||||
return self._pending.is_set()
|
||||
|
||||
def drain(self) -> List[QueuedCommand]:
|
||||
"""Every queued command, oldest first. Called from the render thread."""
|
||||
commands: List[QueuedCommand] = []
|
||||
self._pending.clear()
|
||||
while True:
|
||||
try:
|
||||
commands.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
return commands
|
||||
|
||||
# -- serving -------------------------------------------------------------------
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
while not self._stopping.is_set():
|
||||
sock = self._sock
|
||||
if sock is None:
|
||||
break
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError as e:
|
||||
if self._stopping.is_set():
|
||||
break
|
||||
logger.warning("Control socket accept failed: %s", e)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
if not self._slots.acquire(blocking=False):
|
||||
self._refuse(conn, ErrorCode.BUSY, 'too many connections')
|
||||
continue
|
||||
try:
|
||||
threading.Thread(target=self._serve, args=(conn,), name='ledmatrix-ipc-conn',
|
||||
daemon=True).start()
|
||||
except RuntimeError: # can't start a thread: shed the client
|
||||
self._slots.release()
|
||||
self._refuse(conn, ErrorCode.BUSY, 'server overloaded')
|
||||
|
||||
def _refuse(self, conn: socket.socket, code: str, message: str) -> None:
|
||||
try:
|
||||
conn.settimeout(0.2)
|
||||
conn.sendall(encode_message(Response.failure(None, code, message).to_dict()))
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _serve(self, conn: socket.socket) -> None:
|
||||
"""One connection: authenticate, then answer requests until it ends."""
|
||||
try:
|
||||
conn.settimeout(self._io_timeout)
|
||||
peer = peer_credentials(conn)
|
||||
if self._check_peer and peer is not None and not self._peer_ok(peer):
|
||||
logger.warning("Control socket refused pid %d (uid %d, gid %d): not root, "
|
||||
"this user or group %s", peer.pid, peer.uid, peer.gid, self._group)
|
||||
self._send(conn, Response.failure(None, ErrorCode.FORBIDDEN, 'not permitted'))
|
||||
return
|
||||
self._read_requests(conn, peer)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket connection failed")
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._slots.release()
|
||||
|
||||
def _peer_ok(self, peer: PeerCredentials) -> bool:
|
||||
groups = None
|
||||
if peer.uid not in (0, self._own_uid) and self._group is not None:
|
||||
groups = process_groups(peer.pid)
|
||||
return peer_allowed(peer, self._own_uid, self._group, groups)
|
||||
|
||||
def _read_requests(self, conn: socket.socket, peer: Optional[PeerCredentials]) -> None:
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
idle_since = time.monotonic()
|
||||
message_started: Optional[float] = None
|
||||
while not self._stopping.is_set():
|
||||
now = time.monotonic()
|
||||
if message_started is not None and now - message_started > self._message_timeout:
|
||||
logger.debug("Control socket: dropping a client too slow to send a message")
|
||||
return
|
||||
if message_started is None and now - idle_since > self._idle_timeout:
|
||||
return
|
||||
try:
|
||||
data = conn.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
if not data:
|
||||
return # closed, possibly mid-message: nothing to answer
|
||||
try:
|
||||
lines = reader.feed(data)
|
||||
except ProtocolError as e:
|
||||
self._send(conn, Response.failure(None, e.code, e.message))
|
||||
return # can't find the next message boundary: hang up
|
||||
for line in lines:
|
||||
if not self._send(conn, self.handle_line(line, peer)):
|
||||
return
|
||||
if reader.pending:
|
||||
if message_started is None or lines:
|
||||
message_started = time.monotonic()
|
||||
else:
|
||||
message_started = None
|
||||
idle_since = time.monotonic()
|
||||
|
||||
def _send(self, conn: socket.socket, response: Response) -> bool:
|
||||
try:
|
||||
data = encode_message(response.to_dict())
|
||||
except ProtocolError as e:
|
||||
# A status snapshot too big (or not JSON) to send is a display bug.
|
||||
logger.error("Control socket response not sent: %s", e.message)
|
||||
data = encode_message(Response.failure(
|
||||
response.id, ErrorCode.INTERNAL, 'response could not be encoded').to_dict())
|
||||
try:
|
||||
conn.sendall(data)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# -- requests --------------------------------------------------------------------
|
||||
|
||||
def handle_line(self, line: bytes, peer: Optional[PeerCredentials] = None) -> Response:
|
||||
"""Answer one request line. Never raises."""
|
||||
request_id: Optional[str] = None
|
||||
try:
|
||||
obj = decode_message(line)
|
||||
raw_id = obj.get('id')
|
||||
request_id = raw_id if isinstance(raw_id, str) and len(raw_id) <= 128 else None
|
||||
request = Request.from_dict(obj)
|
||||
request_id = request.id
|
||||
return self._dispatch(request, peer)
|
||||
except ProtocolError as e:
|
||||
return Response.failure(e.request_id or request_id, e.code, e.message)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket handler failed")
|
||||
return Response.failure(request_id, ErrorCode.INTERNAL, 'internal error')
|
||||
|
||||
def _dispatch(self, request: Request, peer: Optional[PeerCredentials]) -> Response:
|
||||
if request.cmd == Command.HELLO:
|
||||
# Exempt from the envelope version check: this is how a client
|
||||
# that speaks other versions finds out which ones we share.
|
||||
hello = HelloArgs.from_dict(request.args)
|
||||
version = negotiate_version(hello.versions)
|
||||
if version is None:
|
||||
return Response.failure(
|
||||
request.id, ErrorCode.UNSUPPORTED_VERSION,
|
||||
f'no common protocol version; this display speaks {list(SUPPORTED_VERSIONS)}')
|
||||
result: HelloResult = {
|
||||
'version': version,
|
||||
'versions': list(SUPPORTED_VERSIONS),
|
||||
'commands': list(COMMANDS),
|
||||
'max_message_bytes': MAX_MESSAGE_BYTES,
|
||||
'server': 'ledmatrix-display',
|
||||
}
|
||||
return Response.success(request.id, dict(result), v=version)
|
||||
|
||||
if request.v not in SUPPORTED_VERSIONS:
|
||||
return Response.failure(
|
||||
request.id, ErrorCode.UNSUPPORTED_VERSION,
|
||||
f'protocol version {request.v} is not supported; '
|
||||
f'this display speaks {list(SUPPORTED_VERSIONS)}')
|
||||
|
||||
try:
|
||||
args = parse_args(request.cmd, request.args)
|
||||
except ProtocolError as e:
|
||||
return Response.failure(request.id, e.code, e.message, v=request.v)
|
||||
|
||||
if request.cmd == Command.PING:
|
||||
return Response.success(request.id, {'pong': True}, v=request.v)
|
||||
|
||||
if request.cmd == Command.ON_DEMAND_STATUS:
|
||||
if self._status_provider is None:
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL, 'no status available',
|
||||
v=request.v)
|
||||
return Response.success(request.id, self._status_provider(), v=request.v)
|
||||
|
||||
if request.cmd in QUEUED_COMMANDS and isinstance(args, (OnDemandStartArgs,
|
||||
OnDemandStopArgs)):
|
||||
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
|
||||
received_at=time.time(),
|
||||
peer_uid=peer.uid if peer is not None else None)
|
||||
try:
|
||||
self._queue.put_nowait(command)
|
||||
except queue.Full:
|
||||
logger.warning("Control socket queue full; refusing %s %s",
|
||||
request.cmd, request.id)
|
||||
return Response.failure(request.id, ErrorCode.BUSY,
|
||||
'the display is not taking commands right now',
|
||||
v=request.v)
|
||||
self._pending.set()
|
||||
ack: AckResult = {'accepted': True, 'request_id': request.id,
|
||||
'queued': self._queue.qsize()}
|
||||
logger.info("Control socket accepted %s %s", request.cmd, request.id)
|
||||
return Response.success(request.id, dict(ack), v=request.v)
|
||||
|
||||
# A command in COMMANDS with no handler here is a bug in this module.
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||
f'{request.cmd} is not implemented', v=request.v)
|
||||
|
||||
|
||||
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
environ: Optional[Mapping[str, str]] = None) -> Optional[ControlServer]:
|
||||
"""Start the display's control socket, or return None when it can't run.
|
||||
|
||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||
bind; in every case the web interface falls back to the file mailbox.
|
||||
"""
|
||||
path = server_socket_path(environ)
|
||||
if path is None:
|
||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||
return None
|
||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir))
|
||||
return server if server.start() else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
|
||||
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
|
||||
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
|
||||
]
|
||||
Reference in New Issue
Block a user