feat: coordinate authoritative playback and audio routing
This commit is contained in:
+11
-1
@@ -31,6 +31,7 @@ class MqttConfig:
|
||||
reconnect_min_seconds: int = 1
|
||||
reconnect_max_seconds: int = 30
|
||||
command_queue_size: int = 512
|
||||
publish_ack_timeout_seconds: float = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -195,6 +196,11 @@ def load_config(
|
||||
reconnect_min_seconds=_integer(mqtt_raw.get("reconnect_min_seconds", 1), "mqtt.reconnect_min_seconds"),
|
||||
reconnect_max_seconds=_integer(mqtt_raw.get("reconnect_max_seconds", 30), "mqtt.reconnect_max_seconds"),
|
||||
command_queue_size=_integer(mqtt_raw.get("command_queue_size", 512), "mqtt.command_queue_size"),
|
||||
publish_ack_timeout_seconds=_number(
|
||||
mqtt_raw.get("publish_ack_timeout_seconds", 2.0),
|
||||
"mqtt.publish_ack_timeout_seconds",
|
||||
minimum=0.1,
|
||||
),
|
||||
)
|
||||
if mqtt.reconnect_max_seconds < mqtt.reconnect_min_seconds:
|
||||
raise ConfigError("mqtt.reconnect_max_seconds must be >= reconnect_min_seconds")
|
||||
@@ -211,7 +217,11 @@ def load_config(
|
||||
playback_base=_text(topics_raw.get("playback_base", "youtube/media/playback"), "topics.playback_base"),
|
||||
router_mode_set=_text(topics_raw.get("router_mode_set", "audio/player/mode/set"), "topics.router_mode_set"),
|
||||
router_mode_state=_text(topics_raw.get("router_mode_state", "audio/player/mode/state"), "topics.router_mode_state"),
|
||||
controller_result=_text(topics_raw.get("controller_result"), "topics.controller_result", allow_none=True),
|
||||
controller_result=_text(
|
||||
topics_raw.get("controller_result", "youtube/controller/v2/events"),
|
||||
"topics.controller_result",
|
||||
allow_none=True,
|
||||
),
|
||||
)
|
||||
|
||||
configured_slots = _integer(storage_raw.get("slot_count", 10), "storage.slot_count")
|
||||
|
||||
@@ -25,7 +25,7 @@ class Topics:
|
||||
playback_base: str = "youtube/media/playback"
|
||||
router_mode_set: str = "audio/player/mode/set"
|
||||
router_mode_state: str = "audio/player/mode/state"
|
||||
controller_result: str | None = None
|
||||
controller_result: str | None = "youtube/controller/v2/events"
|
||||
|
||||
def slot_base(self, slot: int) -> str:
|
||||
if slot not in SLOT_IDS:
|
||||
|
||||
+109
-3
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
@@ -32,6 +34,10 @@ class DownloadEvent:
|
||||
message: str | None = None
|
||||
job_id: str | None = None
|
||||
generation: int | None = None
|
||||
url: str | None = None
|
||||
submitter: str | None = None
|
||||
note: str | None = None
|
||||
reused_slot: bool = False
|
||||
|
||||
|
||||
EventCallback = Callable[[DownloadEvent], None]
|
||||
@@ -66,7 +72,7 @@ class DownloadManager:
|
||||
store: ControllerStore,
|
||||
*,
|
||||
on_event: EventCallback | None = None,
|
||||
runner: Runner = subprocess.run,
|
||||
runner: Runner | None = None,
|
||||
executor: ThreadPoolExecutor | None = None,
|
||||
):
|
||||
self.config = config
|
||||
@@ -80,6 +86,7 @@ class DownloadManager:
|
||||
self._owns_executor = executor is None
|
||||
self._lock = threading.Lock()
|
||||
self._futures: set[Future[Any]] = set()
|
||||
self._processes: set[subprocess.Popen[str]] = set()
|
||||
self._stopping = False
|
||||
|
||||
def enqueue(
|
||||
@@ -106,6 +113,10 @@ class DownloadManager:
|
||||
state="queued",
|
||||
job_id=reservation.job_id,
|
||||
generation=reservation.generation,
|
||||
url=url,
|
||||
submitter=submitter,
|
||||
note=note,
|
||||
reused_slot=reservation.reused_slot,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -184,7 +195,7 @@ class DownloadManager:
|
||||
url,
|
||||
]
|
||||
try:
|
||||
result = self.runner(
|
||||
result = self._run_command(
|
||||
arguments,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
@@ -246,7 +257,7 @@ class DownloadManager:
|
||||
),
|
||||
url,
|
||||
]
|
||||
self.runner(
|
||||
self._run_command(
|
||||
arguments,
|
||||
check=True,
|
||||
text=True,
|
||||
@@ -322,6 +333,90 @@ class DownloadManager:
|
||||
return f"yt-dlp failed with exit code {exc.returncode}"
|
||||
return f"download failed: {exc}"
|
||||
|
||||
def _run_command(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
check: bool,
|
||||
text: bool,
|
||||
timeout: float,
|
||||
capture_output: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if self.runner is not None:
|
||||
return self.runner(
|
||||
arguments,
|
||||
check=check,
|
||||
text=text,
|
||||
timeout=timeout,
|
||||
capture_output=capture_output,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if self._stopping:
|
||||
raise RuntimeError("Controller is shutting down")
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=text,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._processes.add(process)
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=False)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=True)
|
||||
stdout, stderr = process.communicate()
|
||||
raise subprocess.TimeoutExpired(
|
||||
arguments,
|
||||
timeout,
|
||||
output=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
completed = subprocess.CompletedProcess(
|
||||
arguments,
|
||||
process.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
if check and process.returncode:
|
||||
raise subprocess.CalledProcessError(
|
||||
process.returncode,
|
||||
arguments,
|
||||
output=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
return completed
|
||||
finally:
|
||||
with self._lock:
|
||||
self._processes.discard(process)
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
force: bool,
|
||||
) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(
|
||||
process.pid,
|
||||
signal.SIGKILL if force else signal.SIGTERM,
|
||||
)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError:
|
||||
try:
|
||||
process.kill() if force else process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
@@ -387,6 +482,17 @@ class DownloadManager:
|
||||
def shutdown(self, *, wait: bool = True) -> None:
|
||||
with self._lock:
|
||||
self._stopping = True
|
||||
processes = tuple(self._processes)
|
||||
for process in processes:
|
||||
self._terminate_process(process, force=False)
|
||||
deadline = time.monotonic() + 2.0
|
||||
for process in processes:
|
||||
if process.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process, force=True)
|
||||
if self._owns_executor:
|
||||
self._executor.shutdown(wait=wait, cancel_futures=True)
|
||||
|
||||
|
||||
@@ -84,10 +84,38 @@ class PlayerObservation:
|
||||
generation: int
|
||||
reconciliation_state: str
|
||||
reconciliation_due_at: float | None
|
||||
reconciliation_id: str | None
|
||||
last_status_at: float | None
|
||||
last_telemetry_at: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouterObservation:
|
||||
mode: str | None
|
||||
freshness: str
|
||||
observed_at: float | None
|
||||
last_warning: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioLeaseRecord:
|
||||
lease_id: str
|
||||
operation_id: str | None
|
||||
playback_generation: int | None
|
||||
state: str
|
||||
previous_mode: str | None
|
||||
target_mode: str
|
||||
restoration_mode: str | None
|
||||
restoration_source: str
|
||||
controller_changed: bool
|
||||
confirmation_deadline: float | None
|
||||
created_at: float
|
||||
acquired_at: float | None
|
||||
released_at: float | None
|
||||
warning: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationReport:
|
||||
migrated: bool
|
||||
|
||||
+207
-40
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
@@ -33,9 +34,11 @@ class MqttEvent:
|
||||
payload: bytes = b""
|
||||
qos: int = 0
|
||||
retain: bool = False
|
||||
duplicate: bool = False
|
||||
message_id: int | None = None
|
||||
connection_generation: int | None = None
|
||||
reason: str | None = None
|
||||
controller_echo: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,6 +46,7 @@ class PublishReceipt:
|
||||
accepted: bool
|
||||
message_id: int | None = None
|
||||
reason: str | None = None
|
||||
uncertain: bool = False
|
||||
|
||||
|
||||
class MqttAdapter:
|
||||
@@ -59,11 +63,8 @@ class MqttAdapter:
|
||||
self.events: "queue.Queue[MqttEvent]" = events or queue.Queue(
|
||||
maxsize=config.mqtt.command_queue_size
|
||||
)
|
||||
self.client = client or mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=config.mqtt.client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
self._injected_client = client is not None
|
||||
self.client = client or self._new_client()
|
||||
self._generation_lock = threading.Lock()
|
||||
self._connection_generation = 0
|
||||
self._connected = threading.Event()
|
||||
@@ -72,9 +73,18 @@ class MqttAdapter:
|
||||
self._started = False
|
||||
self._loop_started = False
|
||||
self._bootstrap_thread: threading.Thread | None = None
|
||||
self._router_echo_lock = threading.Lock()
|
||||
self._pending_router_echoes: dict[str, bytes] = {}
|
||||
self.last_error: str | None = None
|
||||
self._configure_client()
|
||||
|
||||
def _new_client(self):
|
||||
return mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.config.mqtt.client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
|
||||
def _configure_client(self) -> None:
|
||||
if self.config.mqtt.username is not None:
|
||||
self.client.username_pw_set(
|
||||
@@ -116,20 +126,32 @@ class MqttAdapter:
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
self._begin_async_connection()
|
||||
except (OSError, mqtt.WebsocketConnectionError) as exc:
|
||||
except (OSError, mqtt.WebsocketConnectionError, RuntimeError) as exc:
|
||||
self.last_error = f"initial MQTT setup failed: {exc}"
|
||||
LOG.warning("%s; retrying in background", self.last_error)
|
||||
self._start_bootstrap_retry()
|
||||
|
||||
def _begin_async_connection(self) -> None:
|
||||
self.client.connect_async(
|
||||
with self._lifecycle_lock:
|
||||
if not self._started or self._stop_event.is_set():
|
||||
return
|
||||
client = self.client
|
||||
result = client.connect_async(
|
||||
self.config.mqtt.host,
|
||||
self.config.mqtt.port,
|
||||
keepalive=self.config.mqtt.keepalive_seconds,
|
||||
)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"failed to configure MQTT connection: {result}")
|
||||
with self._lifecycle_lock:
|
||||
if (
|
||||
not self._started
|
||||
or self._stop_event.is_set()
|
||||
or client is not self.client
|
||||
):
|
||||
return
|
||||
if not self._loop_started:
|
||||
result = self.client.loop_start()
|
||||
result = client.loop_start()
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"failed to start MQTT network loop: {result}")
|
||||
self._loop_started = True
|
||||
@@ -159,17 +181,21 @@ class MqttAdapter:
|
||||
delay = min(maximum, max(delay * 2, 1.0))
|
||||
except RuntimeError as exc:
|
||||
self.last_error = str(exc)
|
||||
LOG.error("%s", self.last_error)
|
||||
return
|
||||
LOG.warning("%s", self.last_error)
|
||||
delay = min(maximum, max(delay * 2, 1.0))
|
||||
|
||||
def stop(self) -> None:
|
||||
# Signal callbacks/retry work before waiting for the lifecycle lock.
|
||||
# A callback already inside subscribe/connect then observes shutdown
|
||||
# and cannot publish a post-stop connected state.
|
||||
self._stop_event.set()
|
||||
with self._lifecycle_lock:
|
||||
if not self._started:
|
||||
self._connected.clear()
|
||||
return
|
||||
self._started = False
|
||||
loop_started = self._loop_started
|
||||
self._loop_started = False
|
||||
self._stop_event.set()
|
||||
self._connected.clear()
|
||||
if loop_started:
|
||||
try:
|
||||
@@ -190,24 +216,43 @@ class MqttAdapter:
|
||||
|
||||
def _on_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
||||
try:
|
||||
if not self._reason_success(reason_code):
|
||||
self.last_error = f"MQTT connect failed: {reason_code}"
|
||||
LOG.warning("%s", self.last_error)
|
||||
with self._lifecycle_lock:
|
||||
if (
|
||||
client is not self.client
|
||||
or not self._started
|
||||
or self._stop_event.is_set()
|
||||
):
|
||||
return
|
||||
if not self._reason_success(reason_code):
|
||||
self.last_error = f"MQTT connect failed: {reason_code}"
|
||||
LOG.warning("%s", self.last_error)
|
||||
return
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
with self._generation_lock:
|
||||
self._connection_generation += 1
|
||||
generation = self._connection_generation
|
||||
self._connected.set()
|
||||
self.last_error = None
|
||||
subscriptions = (
|
||||
(self.config.topics.player_status, PLAYER_STATUS_QOS),
|
||||
(self.config.topics.player_telemetry, PLAYER_TELEMETRY_QOS),
|
||||
(self.config.topics.player_state, PLAYER_STATE_QOS),
|
||||
(self.config.topics.controller_command, 1),
|
||||
(self.config.topics.router_mode_set, ROUTER_COMMAND_QOS),
|
||||
(self.config.topics.router_mode_state, 1),
|
||||
)
|
||||
for topic, qos in subscriptions:
|
||||
if self._stop_event.is_set():
|
||||
self._connected.clear()
|
||||
return
|
||||
client.subscribe(topic, qos=qos)
|
||||
if self._stop_event.is_set():
|
||||
self._connected.clear()
|
||||
return
|
||||
if self._stop_event.is_set():
|
||||
self._connected.clear()
|
||||
return
|
||||
with self._generation_lock:
|
||||
self._connection_generation += 1
|
||||
generation = self._connection_generation
|
||||
self._connected.set()
|
||||
self.last_error = None
|
||||
subscriptions = (
|
||||
(self.config.topics.player_status, PLAYER_STATUS_QOS),
|
||||
(self.config.topics.player_telemetry, PLAYER_TELEMETRY_QOS),
|
||||
(self.config.topics.player_state, PLAYER_STATE_QOS),
|
||||
(self.config.topics.controller_command, 1),
|
||||
(self.config.topics.router_mode_state, 1),
|
||||
)
|
||||
for topic, qos in subscriptions:
|
||||
client.subscribe(topic, qos=qos)
|
||||
self._enqueue(
|
||||
MqttEvent(
|
||||
kind="connected",
|
||||
@@ -231,6 +276,8 @@ class MqttAdapter:
|
||||
reason_code,
|
||||
properties=None,
|
||||
) -> None:
|
||||
if client is not self.client:
|
||||
return
|
||||
self._connected.clear()
|
||||
try:
|
||||
self._enqueue(
|
||||
@@ -245,15 +292,33 @@ class MqttAdapter:
|
||||
|
||||
def _on_message(self, client, userdata, message) -> None:
|
||||
try:
|
||||
# Capture client identity and generation atomically with session
|
||||
# replacement. A callback that was already running when an
|
||||
# uncertain command discards the transport must be able to leave a
|
||||
# full queue so loop_stop() cannot deadlock waiting for it.
|
||||
with self._lifecycle_lock:
|
||||
if client is not self.client:
|
||||
return
|
||||
with self._generation_lock:
|
||||
generation = self._connection_generation
|
||||
topic = str(message.topic)
|
||||
payload = bytes(message.payload)
|
||||
retained = bool(getattr(message, "retain", False))
|
||||
self._enqueue(
|
||||
MqttEvent(
|
||||
kind="message",
|
||||
topic=str(message.topic),
|
||||
payload=bytes(message.payload),
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
qos=int(getattr(message, "qos", 0)),
|
||||
retain=bool(getattr(message, "retain", False)),
|
||||
retain=retained,
|
||||
duplicate=bool(getattr(message, "dup", False)),
|
||||
message_id=getattr(message, "mid", None),
|
||||
connection_generation=self.connection_generation,
|
||||
connection_generation=generation,
|
||||
controller_echo=(
|
||||
topic == self.config.topics.router_mode_set
|
||||
and not retained
|
||||
and self._claim_router_echo(payload)
|
||||
),
|
||||
)
|
||||
)
|
||||
except BaseException:
|
||||
@@ -261,6 +326,11 @@ class MqttAdapter:
|
||||
|
||||
def _enqueue(self, event: MqttEvent) -> bool:
|
||||
while not self._stop_event.is_set():
|
||||
if (
|
||||
event.connection_generation is not None
|
||||
and event.connection_generation != self.connection_generation
|
||||
):
|
||||
return False
|
||||
try:
|
||||
self.events.put(event, timeout=0.2)
|
||||
return True
|
||||
@@ -293,8 +363,9 @@ class MqttAdapter:
|
||||
qos: int,
|
||||
retain: bool,
|
||||
require_connection: bool,
|
||||
invalidate_transport_on_uncertain: bool = False,
|
||||
) -> PublishReceipt:
|
||||
if require_connection and not self.connected:
|
||||
if require_connection and (not self.started or not self.connected):
|
||||
return PublishReceipt(False, reason="MQTT is disconnected")
|
||||
try:
|
||||
info = self.client.publish(topic, payload, qos=qos, retain=retain)
|
||||
@@ -303,8 +374,79 @@ class MqttAdapter:
|
||||
return PublishReceipt(False, reason=str(exc))
|
||||
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
return PublishReceipt(False, message_id=getattr(info, "mid", None), reason=f"MQTT rc={info.rc}")
|
||||
if qos > 0 and hasattr(info, "wait_for_publish"):
|
||||
try:
|
||||
info.wait_for_publish(
|
||||
timeout=self.config.mqtt.publish_ack_timeout_seconds
|
||||
)
|
||||
acknowledged = bool(info.is_published())
|
||||
except BaseException as exc:
|
||||
acknowledged = False
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
else:
|
||||
detail = (
|
||||
f"no broker PUBACK within "
|
||||
f"{self.config.mqtt.publish_ack_timeout_seconds:g} seconds"
|
||||
)
|
||||
if not acknowledged:
|
||||
reason = f"MQTT QoS 1 publication is uncertain: {detail}"
|
||||
if invalidate_transport_on_uncertain:
|
||||
self._discard_unacknowledged_transport(reason)
|
||||
return PublishReceipt(
|
||||
False,
|
||||
message_id=getattr(info, "mid", None),
|
||||
reason=reason,
|
||||
uncertain=True,
|
||||
)
|
||||
return PublishReceipt(True, message_id=getattr(info, "mid", None))
|
||||
|
||||
def _discard_unacknowledged_transport(self, reason: str) -> None:
|
||||
"""Drop the Paho session so stale QoS 1 commands cannot replay."""
|
||||
|
||||
if self._injected_client:
|
||||
# Test transports do not own a recreatable Paho session. Production
|
||||
# always follows the replacement path below.
|
||||
with self._generation_lock:
|
||||
self._connection_generation += 1
|
||||
self._connected.clear()
|
||||
return
|
||||
with self._lifecycle_lock:
|
||||
old_client = self.client
|
||||
old_loop_started = self._loop_started
|
||||
self._loop_started = False
|
||||
should_restart = self._started and not self._stop_event.is_set()
|
||||
self._connected.clear()
|
||||
with self._generation_lock:
|
||||
previous_generation = self._connection_generation
|
||||
self._connection_generation += 1
|
||||
self.client = self._new_client()
|
||||
self._configure_client()
|
||||
try:
|
||||
old_client.disconnect()
|
||||
except BaseException:
|
||||
LOG.exception("failed to disconnect uncertain MQTT transport")
|
||||
if old_loop_started:
|
||||
try:
|
||||
old_client.loop_stop()
|
||||
except BaseException:
|
||||
LOG.exception("failed to stop uncertain MQTT transport")
|
||||
self.last_error = reason
|
||||
try:
|
||||
self.events.put_nowait(
|
||||
MqttEvent(
|
||||
kind="disconnected",
|
||||
connection_generation=previous_generation,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
except queue.Full:
|
||||
# This method can run on the sole coordinator thread; blocking on
|
||||
# its own queue would deadlock. The uncertain PublishReceipt still
|
||||
# drives the operation-specific conservative transition.
|
||||
LOG.error("MQTT event queue was full while discarding uncertain transport")
|
||||
if should_restart:
|
||||
self._start_bootstrap_retry()
|
||||
|
||||
@staticmethod
|
||||
def _json_payload(payload: Mapping[str, Any]) -> str:
|
||||
return json.dumps(payload, allow_nan=False, separators=(",", ":"))
|
||||
@@ -316,16 +458,41 @@ class MqttAdapter:
|
||||
qos=PLAYER_COMMAND_QOS,
|
||||
retain=PLAYER_COMMAND_RETAIN,
|
||||
require_connection=True,
|
||||
invalidate_transport_on_uncertain=True,
|
||||
)
|
||||
|
||||
def publish_router_mode(self, mode: str) -> PublishReceipt:
|
||||
return self._publish(
|
||||
self.config.topics.router_mode_set,
|
||||
mode,
|
||||
qos=ROUTER_COMMAND_QOS,
|
||||
retain=ROUTER_COMMAND_RETAIN,
|
||||
require_connection=True,
|
||||
)
|
||||
# A broker normally sends our publication back through the matching
|
||||
# subscription. Register the exact payload before publishing because
|
||||
# Paho's network thread may receive that echo before publish() returns.
|
||||
# The marker is removed as soon as the QoS 1 publish call completes;
|
||||
# this intentionally prefers a false ownership loss over mistaking a
|
||||
# later same-value operator command for a Controller echo.
|
||||
echo_token = uuid.uuid4().hex
|
||||
with self._router_echo_lock:
|
||||
self._pending_router_echoes[echo_token] = mode.encode("utf-8")
|
||||
try:
|
||||
return self._publish(
|
||||
self.config.topics.router_mode_set,
|
||||
mode,
|
||||
qos=ROUTER_COMMAND_QOS,
|
||||
retain=ROUTER_COMMAND_RETAIN,
|
||||
require_connection=True,
|
||||
invalidate_transport_on_uncertain=True,
|
||||
)
|
||||
finally:
|
||||
with self._router_echo_lock:
|
||||
self._pending_router_echoes.pop(echo_token, None)
|
||||
|
||||
def _claim_router_echo(self, payload: bytes) -> bool:
|
||||
"""Claim one exact mode-set echo from an in-flight local publish."""
|
||||
|
||||
with self._router_echo_lock:
|
||||
for token, expected in tuple(self._pending_router_echoes.items()):
|
||||
if expected == payload:
|
||||
del self._pending_router_echoes[token]
|
||||
return True
|
||||
return False
|
||||
|
||||
def publish_legacy(self, topic: str, payload: str, *, retain: bool = True) -> PublishReceipt:
|
||||
return self._publish(
|
||||
|
||||
+400
-73
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -28,6 +29,7 @@ class PlayerOperation:
|
||||
slot: int | None
|
||||
expected_path: str | None
|
||||
deadline: float
|
||||
reconciliation_id: str | None = None
|
||||
state: str = "awaiting_player"
|
||||
message: str | None = None
|
||||
|
||||
@@ -71,6 +73,7 @@ class PlaybackReducer:
|
||||
*,
|
||||
slot: int | None = None,
|
||||
expected_path: str | None = None,
|
||||
operation_id: str | None = None,
|
||||
) -> PlayerOperation:
|
||||
if action not in CONTROL_ACTIONS:
|
||||
raise ValueError(f"unsupported Player action {action!r}")
|
||||
@@ -82,14 +85,13 @@ class PlaybackReducer:
|
||||
)
|
||||
self._operation_generation += 1
|
||||
operation = PlayerOperation(
|
||||
operation_id=uuid.uuid4().hex,
|
||||
operation_id=operation_id or uuid.uuid4().hex,
|
||||
generation=self._operation_generation,
|
||||
action=action,
|
||||
slot=slot,
|
||||
expected_path=expected_path,
|
||||
deadline=self._monotonic() + self.operation_timeout_seconds,
|
||||
)
|
||||
self.pending = operation
|
||||
if action == "play" and slot is not None:
|
||||
self.store.protect_slot(
|
||||
slot,
|
||||
@@ -97,11 +99,40 @@ class PlaybackReducer:
|
||||
guard_id=f"operation-{operation.operation_id}",
|
||||
details={"operation_id": operation.operation_id},
|
||||
)
|
||||
elif action == "play" and expected_path is not None:
|
||||
due = None
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = (
|
||||
self._wall_clock()
|
||||
+ self.config.storage.uncertain_review_after_seconds
|
||||
)
|
||||
observation = self.store.mark_external_uncertain(
|
||||
expected_path,
|
||||
review_after=due,
|
||||
details={
|
||||
"operation_id": operation.operation_id,
|
||||
"reason": "play command pending Player evidence",
|
||||
},
|
||||
)
|
||||
operation = replace(
|
||||
operation,
|
||||
reconciliation_id=observation.reconciliation_id,
|
||||
)
|
||||
self.pending = operation
|
||||
return operation
|
||||
|
||||
def publish_failed(self, reason: str) -> PlaybackEffect | None:
|
||||
if self.pending is None:
|
||||
return None
|
||||
if (
|
||||
self.pending.action == "play"
|
||||
and self.pending.slot is None
|
||||
and self.pending.reconciliation_id is not None
|
||||
):
|
||||
self.store.cancel_external_intent(
|
||||
self.pending.reconciliation_id,
|
||||
reason=reason,
|
||||
)
|
||||
return self._finish_operation("failed", reason)
|
||||
|
||||
def _finish_operation(self, state: str, message: str | None = None) -> PlaybackEffect:
|
||||
@@ -142,11 +173,64 @@ class PlaybackReducer:
|
||||
operation = self.pending
|
||||
if operation is None:
|
||||
return False
|
||||
slot_matches = operation.slot is None or operation.slot == slot
|
||||
path_matches = operation.expected_path is None or operation.expected_path == path
|
||||
if operation.slot is not None and operation.expected_path is not None:
|
||||
return slot_matches or path_matches
|
||||
return slot_matches and path_matches
|
||||
positive_match = False
|
||||
if operation.slot is not None and slot is not None:
|
||||
if operation.slot != slot:
|
||||
return False
|
||||
positive_match = True
|
||||
if operation.expected_path is not None and path is not None:
|
||||
if os.path.abspath(operation.expected_path) != os.path.abspath(path):
|
||||
return False
|
||||
positive_match = True
|
||||
return positive_match
|
||||
|
||||
def _canonical_slot_for_path(self, path: str | None) -> int | None:
|
||||
if path is None:
|
||||
return None
|
||||
candidate = os.path.abspath(path)
|
||||
for slot in SLOT_IDS:
|
||||
slot_directory = os.path.abspath(
|
||||
self.config.storage.media_base / f"slot{slot}"
|
||||
)
|
||||
try:
|
||||
if os.path.commonpath((candidate, slot_directory)) == slot_directory:
|
||||
return slot
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _normalized_identity(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
) -> tuple[int | None, str | None, str | None]:
|
||||
slot = self._slot(payload.get("slot"))
|
||||
path = self._path(payload.get("path"))
|
||||
derived = self._canonical_slot_for_path(path)
|
||||
if slot is not None and derived is not None and slot != derived:
|
||||
return (
|
||||
slot,
|
||||
path,
|
||||
f"Player identity conflicts: slot {slot} does not contain path {path!r}",
|
||||
)
|
||||
return (derived if slot is None else slot), path, None
|
||||
|
||||
@staticmethod
|
||||
def _same_playback_identity(
|
||||
previous: PlayerObservation,
|
||||
slot: int | None,
|
||||
path: str | None,
|
||||
) -> bool:
|
||||
if previous.active_slot != slot:
|
||||
return False
|
||||
if slot is None:
|
||||
return (
|
||||
previous.active_path is not None
|
||||
and path is not None
|
||||
and os.path.abspath(previous.active_path) == os.path.abspath(path)
|
||||
)
|
||||
if previous.active_path is None or path is None:
|
||||
return True
|
||||
return os.path.abspath(previous.active_path) == os.path.abspath(path)
|
||||
|
||||
def _active_evidence(
|
||||
self,
|
||||
@@ -158,11 +242,11 @@ class PlaybackReducer:
|
||||
telemetry: Mapping[str, Any] | None = None,
|
||||
) -> PlayerObservation:
|
||||
previous = self.store.player_observation()
|
||||
if slot is None:
|
||||
if slot is None and (path is None or path == previous.active_path):
|
||||
slot = previous.active_slot
|
||||
if path is None:
|
||||
if path is None and (slot is None or slot == previous.active_slot):
|
||||
path = previous.active_path
|
||||
if paused is None and previous.active_slot == slot:
|
||||
if paused is None and self._same_playback_identity(previous, slot, path):
|
||||
paused = previous.paused
|
||||
phase = "paused" if paused is True else "playing"
|
||||
if slot is not None:
|
||||
@@ -171,6 +255,8 @@ class PlaybackReducer:
|
||||
kind="player_active",
|
||||
details={"path": path},
|
||||
)
|
||||
elif path is not None:
|
||||
self.store.reconcile_external_active()
|
||||
return self.store.set_player_observation(
|
||||
availability="online",
|
||||
phase=phase,
|
||||
@@ -179,6 +265,7 @@ class PlaybackReducer:
|
||||
paused=paused,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=status,
|
||||
telemetry_payload=telemetry,
|
||||
)
|
||||
@@ -199,6 +286,7 @@ class PlaybackReducer:
|
||||
paused=False,
|
||||
reconciliation_state="clear",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=status,
|
||||
telemetry_payload=telemetry,
|
||||
)
|
||||
@@ -212,36 +300,63 @@ class PlaybackReducer:
|
||||
if not isinstance(state, str) or not state:
|
||||
return [PlaybackEffect("warning", message="Player status is missing state")]
|
||||
state = state.strip()
|
||||
slot = self._slot(payload.get("slot"))
|
||||
path = self._path(payload.get("path"))
|
||||
slot, path, identity_warning = self._normalized_identity(payload)
|
||||
if identity_warning is not None:
|
||||
return [PlaybackEffect("warning", slot=slot, message=identity_warning)]
|
||||
effects: list[PlaybackEffect] = []
|
||||
|
||||
if state == "offline":
|
||||
previous = self.store.player_observation()
|
||||
due = None
|
||||
if (
|
||||
previous.active_slot is not None
|
||||
and self.config.storage.uncertain_review_after_seconds > 0
|
||||
):
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.reconcile_active_guard(
|
||||
active_known = (
|
||||
previous.active_slot is not None or previous.active_path is not None
|
||||
)
|
||||
if previous.active_slot is not None:
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
guard = self.store.mark_slot_uncertain(
|
||||
previous.active_slot,
|
||||
kind="restart_uncertain",
|
||||
review_after=due,
|
||||
details={"reason": "Player offline"},
|
||||
)
|
||||
self.store.set_player_observation(
|
||||
availability="offline",
|
||||
phase="unknown" if previous.active_slot is not None else previous.phase,
|
||||
active_slot=previous.active_slot,
|
||||
active_path=previous.active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state=(
|
||||
"awaiting_evidence" if previous.active_slot is not None else "unknown"
|
||||
),
|
||||
reconciliation_due_at=due,
|
||||
status_payload=payload,
|
||||
)
|
||||
due = guard.review_after
|
||||
self.store.set_player_observation(
|
||||
availability="offline",
|
||||
phase="unknown",
|
||||
active_slot=previous.active_slot,
|
||||
active_path=previous.active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state=(
|
||||
"operator_review"
|
||||
if guard.state == "review_required"
|
||||
else "awaiting_evidence"
|
||||
),
|
||||
reconciliation_due_at=due,
|
||||
reconciliation_id=guard.guard_id,
|
||||
status_payload=payload,
|
||||
)
|
||||
elif previous.active_path is not None:
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.mark_external_uncertain(
|
||||
previous.active_path,
|
||||
review_after=due,
|
||||
availability="offline",
|
||||
details={"reason": "Player offline"},
|
||||
status_payload=payload,
|
||||
)
|
||||
else:
|
||||
self.store.set_player_observation(
|
||||
availability="offline",
|
||||
phase=previous.phase,
|
||||
active_slot=None,
|
||||
active_path=None,
|
||||
paused=previous.paused,
|
||||
reconciliation_state="unknown",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=payload,
|
||||
)
|
||||
effects.append(
|
||||
PlaybackEffect(
|
||||
"player_offline",
|
||||
@@ -261,15 +376,56 @@ class PlaybackReducer:
|
||||
message="Reconnect idle announcement carried an active path",
|
||||
)
|
||||
)
|
||||
if self.pending is not None and self.pending.action == "play":
|
||||
if self._matches_pending(slot, path):
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"confirmed", "matching reconnect evidence"
|
||||
)
|
||||
)
|
||||
else:
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"failed",
|
||||
"Player reconnected with different active playback",
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._idle_evidence(status=payload)
|
||||
effects.append(PlaybackEffect("player_idle"))
|
||||
if self.pending is not None and self.pending.action == "stop":
|
||||
effects.append(self._finish_operation("confirmed"))
|
||||
if self.pending is not None:
|
||||
if self.pending.action == "stop":
|
||||
effects.append(self._finish_operation("confirmed"))
|
||||
elif self.pending.action == "play":
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"failed", "Player reconnected without active playback"
|
||||
)
|
||||
)
|
||||
return effects
|
||||
|
||||
if state == "idle":
|
||||
reason = payload.get("reason")
|
||||
if reason not in TERMINAL_REASONS:
|
||||
previous = self.store.player_observation()
|
||||
self.store.set_player_observation(
|
||||
availability="online",
|
||||
phase=previous.phase,
|
||||
active_slot=previous.active_slot,
|
||||
active_path=previous.active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state=previous.reconciliation_state,
|
||||
reconciliation_due_at=previous.reconciliation_due_at,
|
||||
reconciliation_id=previous.reconciliation_id,
|
||||
status_payload=payload,
|
||||
)
|
||||
return [
|
||||
PlaybackEffect(
|
||||
"warning",
|
||||
slot=previous.active_slot,
|
||||
message="ambiguous Player idle status was not treated as terminal",
|
||||
)
|
||||
]
|
||||
self._idle_evidence(status=payload)
|
||||
effects.append(PlaybackEffect("player_idle", slot=slot, message=str(reason or "idle")))
|
||||
if self.pending is not None:
|
||||
@@ -280,29 +436,33 @@ class PlaybackReducer:
|
||||
return effects
|
||||
|
||||
if state == "playing":
|
||||
previous = self.store.player_observation()
|
||||
actual_paused = previous.paused if previous.active_slot == slot else None
|
||||
self._active_evidence(
|
||||
slot=slot,
|
||||
path=path,
|
||||
paused=actual_paused,
|
||||
paused=None,
|
||||
status=payload,
|
||||
)
|
||||
effects.append(PlaybackEffect("player_active", slot=slot))
|
||||
if (
|
||||
self.pending is not None
|
||||
and self.pending.action == "play"
|
||||
and self._matches_pending(slot, path)
|
||||
):
|
||||
effects.append(self._finish_operation("confirmed"))
|
||||
if self._matches_pending(slot, path):
|
||||
effects.append(self._finish_operation("confirmed"))
|
||||
else:
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"failed",
|
||||
"Player became active with a different slot or path",
|
||||
)
|
||||
)
|
||||
return effects
|
||||
|
||||
if state in {"paused", "toggled"}:
|
||||
previous = self.store.player_observation()
|
||||
self._active_evidence(
|
||||
slot=slot,
|
||||
path=path,
|
||||
paused=previous.paused if previous.active_slot == slot else None,
|
||||
paused=None,
|
||||
status=payload,
|
||||
)
|
||||
effects.append(
|
||||
@@ -312,15 +472,24 @@ class PlaybackReducer:
|
||||
message=f"Player issued {state}; awaiting telemetry",
|
||||
)
|
||||
)
|
||||
if self.pending is not None and self.pending.action == "play":
|
||||
if self._matches_pending(slot, path):
|
||||
effects.append(self._finish_operation("confirmed", state))
|
||||
else:
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"failed",
|
||||
"Player control status identified different playback",
|
||||
)
|
||||
)
|
||||
return effects
|
||||
|
||||
if state == "busy":
|
||||
if slot is not None or path is not None:
|
||||
previous = self.store.player_observation()
|
||||
self._active_evidence(
|
||||
slot=slot,
|
||||
path=path,
|
||||
paused=previous.paused if previous.active_slot == slot else None,
|
||||
paused=None,
|
||||
status=payload,
|
||||
)
|
||||
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
|
||||
@@ -338,14 +507,29 @@ class PlaybackReducer:
|
||||
kind="player_active",
|
||||
details={"error": payload.get("message")},
|
||||
)
|
||||
elif active_path is not None:
|
||||
self.store.reconcile_external_active()
|
||||
self.store.set_player_observation(
|
||||
availability="online",
|
||||
phase="error",
|
||||
active_slot=active_slot,
|
||||
active_path=active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state=("confirmed" if active_slot is not None else "unknown"),
|
||||
paused=(
|
||||
previous.paused
|
||||
if self._same_playback_identity(
|
||||
previous,
|
||||
active_slot,
|
||||
active_path,
|
||||
)
|
||||
else None
|
||||
),
|
||||
reconciliation_state=(
|
||||
"confirmed"
|
||||
if active_slot is not None or active_path is not None
|
||||
else "unknown"
|
||||
),
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=payload,
|
||||
)
|
||||
message = str(payload.get("message") or "Player error")
|
||||
@@ -360,8 +544,9 @@ class PlaybackReducer:
|
||||
def handle_telemetry(self, payload: Any) -> list[PlaybackEffect]:
|
||||
if not isinstance(payload, Mapping) or payload.get("state") != "telemetry":
|
||||
return [PlaybackEffect("warning", message="invalid Player telemetry envelope")]
|
||||
slot = self._slot(payload.get("slot"))
|
||||
path = self._path(payload.get("path"))
|
||||
slot, path, identity_warning = self._normalized_identity(payload)
|
||||
if identity_warning is not None:
|
||||
return [PlaybackEffect("warning", slot=slot, message=identity_warning)]
|
||||
effects: list[PlaybackEffect] = []
|
||||
if path is None:
|
||||
self._idle_evidence(telemetry=payload)
|
||||
@@ -383,7 +568,15 @@ class PlaybackReducer:
|
||||
)
|
||||
effects.append(PlaybackEffect("player_telemetry", slot=observation.active_slot))
|
||||
operation = self.pending
|
||||
if operation is None or not self._matches_pending(slot, path):
|
||||
if operation is None:
|
||||
return effects
|
||||
if not self._matches_pending(slot, path):
|
||||
effects.append(
|
||||
self._finish_operation(
|
||||
"failed",
|
||||
"Player telemetry identified a different slot or path",
|
||||
)
|
||||
)
|
||||
return effects
|
||||
if operation.action == "play":
|
||||
effects.append(self._finish_operation("confirmed", "matching telemetry"))
|
||||
@@ -399,48 +592,165 @@ class PlaybackReducer:
|
||||
operation = self.pending
|
||||
if operation is None or self._monotonic() < operation.deadline:
|
||||
return None
|
||||
if operation.action == "play" and operation.slot is not None:
|
||||
due = None
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.reconcile_active_guard(
|
||||
operation.slot,
|
||||
kind="restart_uncertain",
|
||||
review_after=due,
|
||||
details={"operation_id": operation.operation_id, "reason": "play timeout"},
|
||||
)
|
||||
self.store.set_player_observation(
|
||||
availability="unknown",
|
||||
phase="unknown",
|
||||
active_slot=operation.slot,
|
||||
active_path=operation.expected_path,
|
||||
paused=None,
|
||||
reconciliation_state="awaiting_evidence",
|
||||
reconciliation_due_at=due,
|
||||
if operation.action == "play":
|
||||
previous = self.store.player_observation()
|
||||
has_confirmed_active = (
|
||||
previous.reconciliation_state == "confirmed"
|
||||
and (previous.active_slot is not None or previous.active_path is not None)
|
||||
)
|
||||
if has_confirmed_active and self._matches_pending(
|
||||
previous.active_slot,
|
||||
previous.active_path,
|
||||
):
|
||||
return self._finish_operation(
|
||||
"confirmed", "matching authoritative Player evidence"
|
||||
)
|
||||
if not has_confirmed_active:
|
||||
self._mark_play_uncertain(operation, "play timeout")
|
||||
elif operation.action == "stop":
|
||||
previous = self.store.player_observation()
|
||||
if previous.active_slot is not None:
|
||||
due = None
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.reconcile_active_guard(
|
||||
guard = self.store.mark_slot_uncertain(
|
||||
previous.active_slot,
|
||||
kind="restart_uncertain",
|
||||
review_after=due,
|
||||
details={"operation_id": operation.operation_id, "reason": "stop timeout"},
|
||||
)
|
||||
due = guard.review_after
|
||||
self.store.set_player_observation(
|
||||
availability="unknown",
|
||||
phase="unknown",
|
||||
active_slot=previous.active_slot,
|
||||
active_path=previous.active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state="awaiting_evidence",
|
||||
reconciliation_state=(
|
||||
"operator_review"
|
||||
if guard.state == "review_required"
|
||||
else "awaiting_evidence"
|
||||
),
|
||||
reconciliation_due_at=due,
|
||||
reconciliation_id=guard.guard_id,
|
||||
)
|
||||
elif previous.active_path is not None:
|
||||
due = None
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.mark_external_uncertain(
|
||||
previous.active_path,
|
||||
review_after=due,
|
||||
details={
|
||||
"operation_id": operation.operation_id,
|
||||
"reason": "stop timeout",
|
||||
},
|
||||
)
|
||||
return self._finish_operation("timed_out", "no authoritative Player confirmation")
|
||||
|
||||
def _mark_play_uncertain(self, operation: PlayerOperation, reason: str) -> None:
|
||||
due = None
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
slot = operation.slot or self._canonical_slot_for_path(operation.expected_path)
|
||||
if slot is not None:
|
||||
guard = self.store.mark_slot_uncertain(
|
||||
slot,
|
||||
review_after=due,
|
||||
details={"operation_id": operation.operation_id, "reason": reason},
|
||||
)
|
||||
due = guard.review_after
|
||||
self.store.set_player_observation(
|
||||
availability="unknown",
|
||||
phase="unknown",
|
||||
active_slot=slot,
|
||||
active_path=operation.expected_path,
|
||||
paused=None,
|
||||
reconciliation_state=(
|
||||
"operator_review"
|
||||
if guard.state == "review_required"
|
||||
else "awaiting_evidence"
|
||||
),
|
||||
reconciliation_due_at=due,
|
||||
reconciliation_id=guard.guard_id,
|
||||
)
|
||||
elif operation.expected_path is not None:
|
||||
self.store.mark_external_uncertain(
|
||||
operation.expected_path,
|
||||
review_after=due,
|
||||
details={"operation_id": operation.operation_id, "reason": reason},
|
||||
)
|
||||
|
||||
def publication_uncertain(self, reason: str) -> PlaybackEffect | None:
|
||||
if self.pending is None:
|
||||
return None
|
||||
operation = self.pending
|
||||
if operation.action == "play":
|
||||
self._mark_play_uncertain(operation, reason)
|
||||
return self._finish_operation("uncertain", reason)
|
||||
|
||||
def mark_transport_uncertain(self, reason: str) -> PlaybackEffect:
|
||||
previous = self.store.player_observation()
|
||||
pending_play = (
|
||||
self.pending
|
||||
if self.pending is not None and self.pending.action == "play"
|
||||
else None
|
||||
)
|
||||
active_slot = previous.active_slot
|
||||
active_path = previous.active_path
|
||||
if active_slot is None and active_path is None and pending_play is not None:
|
||||
active_slot = pending_play.slot or self._canonical_slot_for_path(
|
||||
pending_play.expected_path
|
||||
)
|
||||
active_path = pending_play.expected_path
|
||||
due = None
|
||||
if active_slot is not None:
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
guard = self.store.mark_slot_uncertain(
|
||||
active_slot,
|
||||
review_after=due,
|
||||
details={"reason": reason},
|
||||
)
|
||||
due = guard.review_after
|
||||
self.store.set_player_observation(
|
||||
availability="unknown",
|
||||
phase="unknown",
|
||||
active_slot=active_slot,
|
||||
active_path=active_path,
|
||||
paused=previous.paused,
|
||||
reconciliation_state=(
|
||||
"operator_review"
|
||||
if guard.state == "review_required"
|
||||
else "awaiting_evidence"
|
||||
),
|
||||
reconciliation_due_at=due,
|
||||
reconciliation_id=guard.guard_id,
|
||||
)
|
||||
elif active_path is not None:
|
||||
if self.config.storage.uncertain_review_after_seconds > 0:
|
||||
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
|
||||
self.store.mark_external_uncertain(
|
||||
active_path,
|
||||
review_after=due,
|
||||
details={"reason": reason},
|
||||
)
|
||||
else:
|
||||
self.store.set_player_observation(
|
||||
availability="unknown",
|
||||
phase=previous.phase,
|
||||
active_slot=None,
|
||||
active_path=None,
|
||||
paused=previous.paused,
|
||||
reconciliation_state="unknown",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
)
|
||||
return PlaybackEffect(
|
||||
"transport_uncertain",
|
||||
slot=active_slot,
|
||||
message=reason,
|
||||
)
|
||||
|
||||
def projected_slot_state(self, slot: int) -> str:
|
||||
record = self.store.get_slot(slot)
|
||||
if record.media_state != "ready":
|
||||
@@ -466,9 +776,9 @@ class PlaybackReducer:
|
||||
return "ready"
|
||||
|
||||
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
|
||||
due = self.store.mark_due_guards_for_review(self._wall_clock())
|
||||
if not due:
|
||||
return []
|
||||
now = self._wall_clock()
|
||||
due = self.store.mark_due_guards_for_review(now)
|
||||
external_due = self.store.mark_external_reconciliation_due(now)
|
||||
observation = self.store.player_observation()
|
||||
if observation.active_slot is not None and any(
|
||||
guard.slot == observation.active_slot for guard in due
|
||||
@@ -481,15 +791,32 @@ class PlaybackReducer:
|
||||
paused=observation.paused,
|
||||
reconciliation_state="operator_review",
|
||||
reconciliation_due_at=observation.reconciliation_due_at,
|
||||
reconciliation_id=observation.reconciliation_id,
|
||||
)
|
||||
return [
|
||||
effects = [
|
||||
PlaybackEffect(
|
||||
"operator_review",
|
||||
slot=guard.slot,
|
||||
operation_id=guard.guard_id,
|
||||
message=(
|
||||
"Playback remains unknown and protected; fresh Player evidence "
|
||||
"or an administrative clear is required"
|
||||
"or an administrative clear is required "
|
||||
f"(review_id={guard.guard_id})"
|
||||
),
|
||||
)
|
||||
for guard in due
|
||||
]
|
||||
if external_due is not None:
|
||||
review_id, path = external_due
|
||||
effects.append(
|
||||
PlaybackEffect(
|
||||
"operator_review",
|
||||
operation_id=review_id,
|
||||
message=(
|
||||
f"Playback at {path!r} remains unknown and protected; "
|
||||
"fresh Player evidence or an administrative clear is required "
|
||||
f"(review_id={review_id})"
|
||||
),
|
||||
)
|
||||
)
|
||||
return effects
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Timeout-bounded Audio Router acquisition and restoration leases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .config import ControllerConfig
|
||||
from .models import AudioLeaseRecord
|
||||
from .mqtt_adapter import PublishReceipt
|
||||
from .store import ControllerStore
|
||||
|
||||
|
||||
ModePublisher = Callable[[str], PublishReceipt]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouterDecision:
|
||||
action: str
|
||||
lease_id: str | None = None
|
||||
warning: str | None = None
|
||||
continued: bool = False
|
||||
|
||||
|
||||
class AudioRouterCoordinator:
|
||||
"""Own one conservative Router lease for one playback session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ControllerConfig,
|
||||
store: ControllerStore,
|
||||
publish_mode: ModePublisher,
|
||||
*,
|
||||
clock=time.monotonic,
|
||||
):
|
||||
self.config = config
|
||||
self.store = store
|
||||
self.publish_mode = publish_mode
|
||||
self._clock = clock
|
||||
|
||||
def observe_mode(self, mode: str) -> RouterDecision:
|
||||
mode = mode.strip()
|
||||
if not mode:
|
||||
warning = "Audio Router published an empty mode"
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("warning", warning=warning)
|
||||
self.store.set_router_observation(mode=mode, freshness="current")
|
||||
lease = self.store.live_audio_lease()
|
||||
if lease is None:
|
||||
return RouterDecision("observed")
|
||||
|
||||
if lease.state == "acquiring":
|
||||
if mode == lease.target_mode:
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="held",
|
||||
confirmation_deadline=None,
|
||||
acquired=True,
|
||||
)
|
||||
return RouterDecision("ready", lease_id=lease.lease_id)
|
||||
# State is evidence of the Router's current output, not evidence
|
||||
# that an operator issued a command. It may simply be a periodic
|
||||
# repeat of the pre-acquisition state, so keep waiting for the
|
||||
# finite confirmation deadline.
|
||||
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||
|
||||
if lease.state == "held":
|
||||
if mode != lease.target_mode:
|
||||
warning = (
|
||||
f"Audio Router changed from Controller-owned {lease.target_mode!r} "
|
||||
f"to {mode!r}; automatic restoration was relinquished"
|
||||
)
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="ownership_lost",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||
|
||||
if lease.state == "unconfirmed":
|
||||
# A late state event must never resurrect a lease that already
|
||||
# timed out or deliberately continued without confirmation.
|
||||
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||
|
||||
if lease.state == "restore_pending":
|
||||
if mode == lease.restoration_mode:
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="released",
|
||||
confirmation_deadline=None,
|
||||
released=True,
|
||||
)
|
||||
return RouterDecision("restored", lease_id=lease.lease_id)
|
||||
# The Router may repeat its current state before applying the
|
||||
# restoration command. Wait for the bounded deadline; explicit
|
||||
# mode-set intent is handled separately below.
|
||||
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||
return RouterDecision("observed", lease_id=lease.lease_id)
|
||||
|
||||
def observe_mode_command(
|
||||
self,
|
||||
mode: str,
|
||||
*,
|
||||
retained: bool = False,
|
||||
controller_echo: bool = False,
|
||||
) -> RouterDecision:
|
||||
"""Observe a Router mode-set publication without treating state as intent.
|
||||
|
||||
The MQTT adapter marks an exact echo received while a Controller QoS 1
|
||||
publication is in flight. Every other live mode-set publication is
|
||||
conservatively treated as operator intent. Retained commands are old
|
||||
broker state and can never establish current operator intent.
|
||||
"""
|
||||
|
||||
mode = mode.strip()
|
||||
if not mode:
|
||||
warning = "Audio Router published an empty mode command"
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("warning", warning=warning)
|
||||
|
||||
lease = self.store.live_audio_lease()
|
||||
lease_id = None if lease is None else lease.lease_id
|
||||
if retained:
|
||||
warning = (
|
||||
f"Retained Audio Router mode command {mode!r} was ignored as stale intent"
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("retained_ignored", lease_id, warning)
|
||||
if controller_echo:
|
||||
return RouterDecision("self_echo", lease_id=lease_id)
|
||||
if lease is None:
|
||||
return RouterDecision("operator_intent")
|
||||
|
||||
if lease.state == "acquiring":
|
||||
return self._acquisition_failed(
|
||||
lease.lease_id,
|
||||
(
|
||||
f"Audio Router received an operator mode command for {mode!r} "
|
||||
f"while {lease.target_mode!r} confirmation was pending; "
|
||||
"automatic restoration ownership was relinquished"
|
||||
),
|
||||
)
|
||||
|
||||
if lease.state in {"held", "unconfirmed", "restore_pending"}:
|
||||
warning = (
|
||||
f"Audio Router received an operator mode command for {mode!r}; "
|
||||
"automatic restoration was relinquished"
|
||||
)
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="ownership_lost",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||
return RouterDecision("operator_intent", lease_id=lease.lease_id)
|
||||
|
||||
def acquire(self, operation_id: str) -> RouterDecision:
|
||||
observation = self.store.router_observation()
|
||||
known = observation.freshness == "current" and observation.mode is not None
|
||||
target = self.config.router.required_mode
|
||||
previous = observation.mode if known else None
|
||||
lease_id = uuid.uuid4().hex
|
||||
|
||||
if known and previous == target:
|
||||
self.store.create_audio_lease(
|
||||
lease_id=lease_id,
|
||||
operation_id=operation_id,
|
||||
state="held",
|
||||
previous_mode=previous,
|
||||
target_mode=target,
|
||||
restoration_mode=None,
|
||||
restoration_source="none",
|
||||
controller_changed=False,
|
||||
confirmation_deadline=None,
|
||||
)
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="held",
|
||||
acquired=True,
|
||||
)
|
||||
return RouterDecision("ready", lease_id=lease_id)
|
||||
|
||||
if known:
|
||||
restoration_mode = previous
|
||||
restoration_source = "observed_previous"
|
||||
elif self.config.router.fallback_restore_mode is not None:
|
||||
restoration_mode = self.config.router.fallback_restore_mode
|
||||
restoration_source = "configured_fallback"
|
||||
else:
|
||||
restoration_mode = None
|
||||
restoration_source = "none"
|
||||
|
||||
policy = self.config.router.when_state_unknown
|
||||
if not known and policy == "reject":
|
||||
warning = "Audio Router state is unavailable; playback was rejected by policy"
|
||||
self.store.create_audio_lease(
|
||||
lease_id=lease_id,
|
||||
operation_id=operation_id,
|
||||
state="abandoned",
|
||||
previous_mode=None,
|
||||
target_mode=target,
|
||||
restoration_mode=restoration_mode,
|
||||
restoration_source=restoration_source,
|
||||
controller_changed=False,
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("rejected", lease_id, warning)
|
||||
|
||||
deadline = self._clock() + self.config.router.confirmation_timeout_seconds
|
||||
initial_state = "unconfirmed" if not known and policy == "continue" else "acquiring"
|
||||
self.store.create_audio_lease(
|
||||
lease_id=lease_id,
|
||||
operation_id=operation_id,
|
||||
state=initial_state,
|
||||
previous_mode=previous,
|
||||
target_mode=target,
|
||||
restoration_mode=restoration_mode,
|
||||
restoration_source=restoration_source,
|
||||
controller_changed=True,
|
||||
confirmation_deadline=(deadline if initial_state == "acquiring" else None),
|
||||
)
|
||||
receipt = self.publish_mode(target)
|
||||
if not receipt.accepted:
|
||||
return self._acquisition_failed(
|
||||
lease_id,
|
||||
f"could not request Audio Router mode {target!r}: {receipt.reason}",
|
||||
)
|
||||
|
||||
if initial_state == "unconfirmed":
|
||||
warning = (
|
||||
f"Audio Router state is unavailable; requested {target!r} and "
|
||||
"continued immediately without restoration ownership"
|
||||
)
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="unconfirmed",
|
||||
warning=warning,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("ready", lease_id, warning, continued=True)
|
||||
return RouterDecision("waiting", lease_id=lease_id)
|
||||
|
||||
def check_deadline(self) -> RouterDecision | None:
|
||||
lease = self.store.live_audio_lease()
|
||||
if (
|
||||
lease is None
|
||||
or lease.confirmation_deadline is None
|
||||
or self._clock() < lease.confirmation_deadline
|
||||
):
|
||||
return None
|
||||
if lease.state == "acquiring":
|
||||
return self._acquisition_failed(
|
||||
lease.lease_id,
|
||||
(
|
||||
f"Audio Router did not confirm {lease.target_mode!r} within "
|
||||
f"{self.config.router.confirmation_timeout_seconds:g} seconds"
|
||||
),
|
||||
)
|
||||
if lease.state == "restore_pending":
|
||||
warning = (
|
||||
f"Audio Router did not confirm restoration to {lease.restoration_mode!r}; "
|
||||
"no further automatic change will be attempted"
|
||||
)
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="abandoned",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("restore_failed", lease.lease_id, warning)
|
||||
return None
|
||||
|
||||
def _acquisition_failed(self, lease_id: str, warning: str) -> RouterDecision:
|
||||
continue_playback = self.config.router.continue_on_confirmation_failure
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="unconfirmed" if continue_playback else "abandoned",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=not continue_playback,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision(
|
||||
"ready" if continue_playback else "rejected",
|
||||
lease_id,
|
||||
warning,
|
||||
continued=continue_playback,
|
||||
)
|
||||
|
||||
def release(self, lease_id: str | None) -> RouterDecision:
|
||||
if lease_id is None:
|
||||
return RouterDecision("released")
|
||||
lease = self.store.get_audio_lease(lease_id)
|
||||
if lease is None:
|
||||
return RouterDecision("released")
|
||||
if lease.state == "held" and not lease.controller_changed:
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="released",
|
||||
confirmation_deadline=None,
|
||||
released=True,
|
||||
)
|
||||
return RouterDecision("released", lease_id)
|
||||
if lease.state != "held":
|
||||
if lease.state in {"unconfirmed", "acquiring"}:
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="abandoned",
|
||||
confirmation_deadline=None,
|
||||
warning="Audio Router was never confirmed; restoration was skipped",
|
||||
released=True,
|
||||
)
|
||||
return RouterDecision("released", lease_id)
|
||||
|
||||
observation = self.store.router_observation()
|
||||
if observation.freshness != "current" or observation.mode != lease.target_mode:
|
||||
warning = "Audio Router ownership is no longer current; restoration was skipped"
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="ownership_lost",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("ownership_lost", lease_id, warning)
|
||||
if lease.restoration_mode is None:
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="released",
|
||||
confirmation_deadline=None,
|
||||
released=True,
|
||||
)
|
||||
return RouterDecision("released", lease_id)
|
||||
|
||||
receipt = self.publish_mode(lease.restoration_mode)
|
||||
if not receipt.accepted:
|
||||
warning = f"could not request Audio Router restoration: {receipt.reason}"
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="abandoned",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("restore_failed", lease_id, warning)
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="restore_pending",
|
||||
confirmation_deadline=(
|
||||
self._clock() + self.config.router.confirmation_timeout_seconds
|
||||
),
|
||||
)
|
||||
return RouterDecision("restore_waiting", lease_id)
|
||||
|
||||
def connection_lost(self) -> RouterDecision | None:
|
||||
observation = self.store.router_observation()
|
||||
self.store.set_router_observation(
|
||||
mode=observation.mode,
|
||||
freshness="stale",
|
||||
warning="MQTT continuity was lost",
|
||||
)
|
||||
lease = self.store.live_audio_lease()
|
||||
if lease is None:
|
||||
return None
|
||||
if lease.state == "acquiring":
|
||||
return self._acquisition_failed(
|
||||
lease.lease_id,
|
||||
"MQTT disconnected while waiting for Audio Router confirmation",
|
||||
)
|
||||
warning = "MQTT continuity was lost; automatic Audio Router restoration was relinquished"
|
||||
self.store.update_audio_lease(
|
||||
lease.lease_id,
|
||||
state="ownership_lost",
|
||||
confirmation_deadline=None,
|
||||
warning=warning,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(warning)
|
||||
return RouterDecision("ownership_lost", lease.lease_id, warning)
|
||||
|
||||
def abandon(self, lease_id: str | None, reason: str) -> None:
|
||||
if lease_id is None:
|
||||
return
|
||||
lease = self.store.get_audio_lease(lease_id)
|
||||
if lease is None or lease.state in {"released", "abandoned", "ownership_lost"}:
|
||||
return
|
||||
self.store.update_audio_lease(
|
||||
lease_id,
|
||||
state="abandoned",
|
||||
confirmation_deadline=None,
|
||||
warning=reason,
|
||||
released=True,
|
||||
)
|
||||
self._record_warning(reason)
|
||||
|
||||
def _record_warning(self, warning: str) -> None:
|
||||
observation = self.store.router_observation()
|
||||
self.store.set_router_observation(
|
||||
mode=observation.mode,
|
||||
freshness=observation.freshness,
|
||||
warning=warning,
|
||||
)
|
||||
self.store.record_audit_event(
|
||||
"audio_router_warning",
|
||||
actor="controller",
|
||||
details={"message": warning},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+865
-65
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user