feat: coordinate authoritative playback and audio routing

This commit is contained in:
Media
2026-08-06 18:52:01 -05:00
parent 8b3bdae8a0
commit 3ee7f94d72
17 changed files with 4863 additions and 186 deletions
+207 -40
View File
@@ -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(