mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-20 13:39:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
548 lines
24 KiB
Python
548 lines
24 KiB
Python
"""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``. Nothing here blocks for longer than ``timeout`` in total.
|
|
|
|
There is no other way to reach the display: the file mailboxes the web
|
|
interface used to fall back to are gone. :func:`display_not_listening` tells
|
|
a caller when no display is listening yet (it is stopped, or still
|
|
starting), the one case where sending the same request again later, once a
|
|
display is up, can work.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
|
|
|
from src.ipc.contract import (
|
|
AWAIT_SECONDS,
|
|
MAX_MESSAGE_BYTES,
|
|
PROTOCOL_VERSION,
|
|
SUBSCRIBE_KEEPALIVE_SECONDS,
|
|
SUPPORTED_VERSIONS,
|
|
Command,
|
|
ErrorCode,
|
|
FrameReader,
|
|
ProtocolError,
|
|
Request,
|
|
Response,
|
|
StateEvent,
|
|
StateEventKind,
|
|
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.
|
|
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``, ...).
|
|
|
|
``sent`` is True once the whole request was written to a connected
|
|
display, which may then have acted on it. A refusal the display sends
|
|
before it reads anything (``forbidden``, too many connections) carries
|
|
no request id and leaves ``sent`` False.
|
|
"""
|
|
|
|
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
|
|
super().__init__(reason, message)
|
|
self.reason = reason
|
|
self.message = message
|
|
self.sent = sent
|
|
|
|
def __str__(self) -> str:
|
|
return f'{self.reason}: {self.message}' if self.message else self.reason
|
|
|
|
|
|
#: Answers from a display that read the request but does not speak it: one
|
|
#: older than the command (an upgrade in progress) or the protocol version.
|
|
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
|
|
|
#: Transport reasons that mean nothing is listening at the socket: no socket
|
|
#: file (the display is stopped, or has not reached its run loop), or a file
|
|
#: nobody accepts on (a stale socket) or that this user may not open.
|
|
NOT_LISTENING_REASONS = frozenset({'no_socket', 'refused'})
|
|
|
|
|
|
def display_not_listening(error: BaseException) -> bool:
|
|
"""True when ``error`` says no display took the request because none is
|
|
listening: it never reached one (``sent`` is False) and the reason is in
|
|
:data:`NOT_LISTENING_REASONS`. A display that is started, or finishes
|
|
starting, may take the same request later. False for everything else:
|
|
a display that had the request and failed it, one too old to know the
|
|
command, a client that cannot use the socket at all (``disabled``,
|
|
``unsupported``), and an exception that is not a :class:`ControlError`.
|
|
"""
|
|
return (isinstance(error, ControlError) and not error.sent
|
|
and error.reason in NOT_LISTENING_REASONS)
|
|
|
|
|
|
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', sent=True)
|
|
if not response.ok:
|
|
error = response.error
|
|
# No id: refused at the door (forbidden, too many connections),
|
|
# before the display read the request.
|
|
raise ControlError(error.code if error else 'bad_response',
|
|
error.message if error else '',
|
|
sent=response.id is not None)
|
|
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:
|
|
"""Send ``payload`` and read the reply. A failure once the whole request
|
|
is written raises with ``sent=True``: the display may have it."""
|
|
sent = False
|
|
try:
|
|
sock.settimeout(_remaining(deadline))
|
|
sock.sendall(payload)
|
|
sent = True
|
|
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', sent=sent)
|
|
lines = reader.feed(data)
|
|
if lines:
|
|
return Response.from_dict(decode_message(lines[0]))
|
|
except socket.timeout:
|
|
raise ControlError('timeout', 'no reply in time', sent=sent) from None
|
|
except ProtocolError as e:
|
|
raise ControlError('bad_response', e.message, sent=sent) from None
|
|
except ControlError as e:
|
|
e.sent = e.sent or sent
|
|
raise
|
|
except OSError as e:
|
|
raise ControlError('closed', str(e), sent=sent) 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 sent
|
|
twice with the same id 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)
|
|
|
|
|
|
#: Headroom over the display's own wait for an awaited command, so its
|
|
#: ``pending`` answer arrives before the client gives up.
|
|
_AWAIT_MARGIN_SECONDS = 1.0
|
|
|
|
|
|
def _awaited_timeout(cmd: str) -> float:
|
|
return AWAIT_SECONDS[cmd] + _AWAIT_MARGIN_SECONDS
|
|
|
|
|
|
def brightness_set(brightness: int, *, timeout: Optional[float] = None,
|
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
"""Set the panel's normal brightness now (transient: config.json is not
|
|
written). Returns the applied :class:`~src.ipc.contract.BrightnessResult`;
|
|
raises :class:`ControlError`.
|
|
"""
|
|
return request(Command.BRIGHTNESS_SET, {'brightness': brightness},
|
|
timeout=_awaited_timeout(Command.BRIGHTNESS_SET) if timeout is None
|
|
else timeout, paths=paths)
|
|
|
|
|
|
def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
|
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
"""Have the display reload a running plugin from disk.
|
|
|
|
Returns :class:`~src.ipc.contract.PluginReloadResult` once the new code is
|
|
running. Raises :class:`ControlError`: ``not_loaded`` (not running it),
|
|
``failed`` (the new version did not load), ``pending`` (not done in
|
|
time; it will still happen), or a transport reason.
|
|
"""
|
|
return request(Command.PLUGIN_RELOAD, {'plugin_id': plugin_id},
|
|
timeout=_awaited_timeout(Command.PLUGIN_RELOAD) if timeout is None
|
|
else timeout, paths=paths)
|
|
|
|
|
|
def errors_clear(request_id: str, cutoff: float, *,
|
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
"""Have the display forget the plugin errors recorded at or before
|
|
``cutoff`` (epoch seconds) and publish its error snapshot again.
|
|
|
|
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
|
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
|
than the command.
|
|
"""
|
|
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
|
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)
|
|
|
|
|
|
# -- the state stream (stage 3) ---------------------------------------------------------
|
|
|
|
def state_get(since: Optional[int] = None, epoch: Optional[str] = None, *,
|
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
"""The display's state now, as a :class:`~src.ipc.contract.StateSnapshot`.
|
|
|
|
With ``since``/``epoch`` from an earlier answer, an unchanged state comes
|
|
back in the short ``changed: false`` form. Raises :class:`ControlError`
|
|
(``unknown_command`` from a display older than stage 3).
|
|
"""
|
|
args: Dict[str, Any] = {}
|
|
if since is not None:
|
|
args['since'] = since
|
|
if epoch is not None:
|
|
args['epoch'] = epoch
|
|
return request(Command.STATE_GET, args, timeout=timeout, paths=paths)
|
|
|
|
|
|
def snapshot_age(snapshot: Mapping[str, Any], now_mono: Optional[float] = None) -> float:
|
|
"""Seconds since ``snapshot`` arrived: ``received_mono`` (set by
|
|
:meth:`StateSubscription.latest`) to now; 0 for a one-shot answer."""
|
|
received = snapshot.get('received_mono')
|
|
if isinstance(received, (int, float)) and not isinstance(received, bool):
|
|
now_mono = time.monotonic() if now_mono is None else now_mono
|
|
return max(now_mono - float(received), 0.0)
|
|
return 0.0
|
|
|
|
|
|
def snapshot_loop_age(snapshot: Mapping[str, Any],
|
|
now_mono: Optional[float] = None) -> Optional[float]:
|
|
"""The render loop's heartbeat age now, from a state snapshot: the age the
|
|
display measured when it answered, plus the time since the answer
|
|
arrived. None when the display has no beat to report yet."""
|
|
loop = snapshot.get('loop')
|
|
if not isinstance(loop, dict):
|
|
state = snapshot.get('state')
|
|
loop = state.get('loop') if isinstance(state, dict) else None
|
|
age = loop.get('heartbeat_age_seconds') if isinstance(loop, dict) else None
|
|
if not isinstance(age, (int, float)) or isinstance(age, bool):
|
|
return None
|
|
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
|
|
|
|
|
def _merge_volatile(state: Dict[str, Any], volatile: Any) -> None:
|
|
"""Fold a tick's ``volatile`` values (``{section: {key: value}}``) into
|
|
``state``, copying each section it touches.
|
|
|
|
These are the timestamps the hub leaves out of its version --
|
|
``display.last_updated``, ``on_demand.last_updated``/``remaining``,
|
|
``plugins.published_at`` -- and the readers judge freshness by them, so
|
|
a copy that only full ``state`` events updated would go stale while the
|
|
same mode stayed on screen. Only keys the section already has are taken:
|
|
a tick never adds a section or a key the last snapshot did not carry
|
|
(a section left out of a truncated snapshot stays out).
|
|
"""
|
|
if not isinstance(volatile, dict):
|
|
return # a display from before ticks carried them
|
|
for name, values in volatile.items():
|
|
section = state.get(name)
|
|
if not isinstance(section, dict) or not isinstance(values, dict):
|
|
continue
|
|
fresh = {k: v for k, v in values.items() if k in section}
|
|
if fresh:
|
|
state[name] = dict(section, **fresh)
|
|
|
|
|
|
#: A subscription that has heard nothing for this long is not trusted: the
|
|
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
|
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
|
|
|
#: Reconnect backoff: the first retry, and the cap. A display that does not
|
|
#: know state.subscribe (stage 2 or older) is retried at the cap.
|
|
_RECONNECT_MIN_SECONDS = 1.0
|
|
_RECONNECT_MAX_SECONDS = 30.0
|
|
|
|
#: Failures that another try soon will not fix.
|
|
_SLOW_RETRY_REASONS = frozenset({'unknown_command', 'unsupported_version', 'disabled',
|
|
'unsupported'})
|
|
|
|
|
|
class StateSubscription:
|
|
"""One ``state.subscribe`` connection, held on a daemon thread.
|
|
|
|
Keeps the latest snapshot the display pushed, so a reader answers from
|
|
memory (:meth:`latest`). Reconnects with a backoff when the display goes
|
|
away. Never raises into the caller: :meth:`latest` is None whenever the
|
|
copy cannot be vouched for (not connected, or silent for longer than
|
|
``silence``), and the caller falls back.
|
|
"""
|
|
|
|
def __init__(self, paths: Optional[Sequence[str]] = None, *,
|
|
silence: float = SUBSCRIPTION_SILENCE_SECONDS,
|
|
connect_timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
clock: Callable[[], float] = time.monotonic):
|
|
self._paths = list(paths) if paths is not None else None
|
|
self._silence = silence
|
|
self._connect_timeout = connect_timeout
|
|
self._clock = clock
|
|
self._lock = threading.Lock()
|
|
self._snapshot: Optional[Dict[str, Any]] = None
|
|
self._received: Optional[float] = None
|
|
self._connected = False
|
|
self._stop = threading.Event()
|
|
self._sock: Optional[socket.socket] = None
|
|
self._thread: Optional[threading.Thread] = None
|
|
#: The reason the last connection ended (a ControlError reason).
|
|
self.last_error: Optional[str] = None
|
|
#: Full snapshots received: the subscribe answer and each state event.
|
|
self.snapshots = 0
|
|
|
|
# -- the reader's side ---------------------------------------------------
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
return self._connected
|
|
|
|
def latest(self) -> Optional[Dict[str, Any]]:
|
|
"""A copy of the latest snapshot, with ``received_mono`` (this
|
|
process's monotonic clock when it arrived); None when not trusted."""
|
|
with self._lock:
|
|
if not self._connected or self._snapshot is None or self._received is None:
|
|
return None
|
|
if self._clock() - self._received > self._silence:
|
|
return None
|
|
snap = dict(self._snapshot)
|
|
snap['received_mono'] = self._received
|
|
return snap
|
|
|
|
# -- lifecycle -----------------------------------------------------------
|
|
|
|
def start(self) -> 'StateSubscription':
|
|
if self._thread is None or not self._thread.is_alive():
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._run, name='ledmatrix-state-feed',
|
|
daemon=True)
|
|
self._thread.start()
|
|
return self
|
|
|
|
def stop(self, timeout: float = 2.0) -> None:
|
|
self._stop.set()
|
|
sock = self._sock
|
|
if sock is not None:
|
|
try:
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
thread = self._thread
|
|
if thread is not None and thread is not threading.current_thread():
|
|
thread.join(timeout)
|
|
self._thread = None
|
|
|
|
# -- the feed thread -----------------------------------------------------
|
|
|
|
def _run(self) -> None:
|
|
backoff = _RECONNECT_MIN_SECONDS
|
|
while not self._stop.is_set():
|
|
snapshots = self.snapshots
|
|
try:
|
|
self._follow()
|
|
except ControlError as e:
|
|
self.last_error = e.reason
|
|
if e.reason in _SLOW_RETRY_REASONS:
|
|
backoff = _RECONNECT_MAX_SECONDS
|
|
except Exception as e: # pylint: disable=broad-except
|
|
self.last_error = type(e).__name__
|
|
finally:
|
|
with self._lock:
|
|
self._connected = False
|
|
sock, self._sock = self._sock, None
|
|
if sock is not None:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
if self.snapshots != snapshots:
|
|
# This connection got as far as the display's state: whatever
|
|
# ended it (a restart, most often), it was working, so the
|
|
# next try starts from the shortest wait again.
|
|
backoff = _RECONNECT_MIN_SECONDS
|
|
if self._stop.wait(backoff):
|
|
return
|
|
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
|
|
|
def _follow(self) -> None:
|
|
"""Subscribe, then read events until the connection ends. Raises ControlError."""
|
|
if not socket_supported():
|
|
raise ControlError('unsupported', 'no Unix sockets on this platform')
|
|
candidates = list(self._paths) if self._paths is not None else client_socket_paths()
|
|
if not candidates:
|
|
raise ControlError('disabled', 'the control socket is turned off')
|
|
request_id = str(uuid.uuid4())
|
|
payload = encode_message(Request(id=request_id, cmd=Command.STATE_SUBSCRIBE,
|
|
args={}).to_dict())
|
|
sock = _connect(candidates, time.monotonic() + self._connect_timeout)
|
|
self._sock = sock
|
|
try:
|
|
sock.settimeout(self._connect_timeout)
|
|
sock.sendall(payload)
|
|
# A read waits for the next event; the display sends one at least
|
|
# every keepalive, so this much silence means it is gone.
|
|
sock.settimeout(self._silence)
|
|
reader = FrameReader(MAX_MESSAGE_BYTES)
|
|
first = True
|
|
while not self._stop.is_set():
|
|
data = sock.recv(65536)
|
|
if not data:
|
|
raise ControlError('closed', 'the display closed the connection')
|
|
for line in reader.feed(data):
|
|
obj = decode_message(line)
|
|
if first:
|
|
response = Response.from_dict(obj)
|
|
if not response.ok:
|
|
error = response.error
|
|
raise ControlError(error.code if error else 'bad_response',
|
|
error.message if error else '')
|
|
self._store(dict(response.result or {}), full=True)
|
|
first = False
|
|
continue
|
|
event = StateEvent.from_dict(obj)
|
|
self._store(event.result, full=event.event == StateEventKind.STATE)
|
|
except socket.timeout:
|
|
raise ControlError('timeout', 'the display went quiet') from None
|
|
except ProtocolError as e:
|
|
raise ControlError('bad_response', e.message) from None
|
|
except OSError as e:
|
|
if self._stop.is_set():
|
|
return
|
|
raise ControlError('closed', str(e)) from None
|
|
|
|
def _store(self, result: Dict[str, Any], full: bool) -> None:
|
|
now = self._clock()
|
|
with self._lock:
|
|
if full and isinstance(result.get('state'), dict):
|
|
self._snapshot = result
|
|
self.snapshots += 1
|
|
elif (self._snapshot is not None
|
|
and result.get('epoch') == self._snapshot.get('epoch')):
|
|
# A tick: nothing changed but the render loop's liveness and
|
|
# the volatile keys (timestamps) the writers keep refreshing.
|
|
snap = dict(self._snapshot)
|
|
state = dict(snap.get('state') or {})
|
|
if result.get('version') == snap.get('version'):
|
|
_merge_volatile(state, result.get('volatile'))
|
|
loop = result.get('loop')
|
|
if isinstance(loop, dict):
|
|
state['loop'] = loop
|
|
snap['loop'] = loop
|
|
snap['state'] = state
|
|
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
|
self._snapshot = snap
|
|
else:
|
|
return # a tick before any state, or from another epoch
|
|
self._received = now
|
|
self._connected = True
|