feat: reduce authoritative player mqtt state

This commit is contained in:
Media
2026-08-06 17:41:07 -05:00
parent 45083207b4
commit 8b3bdae8a0
5 changed files with 1357 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
"""Resilient MQTT transport with explicit Player command guarantees."""
from __future__ import annotations
import json
import logging
import queue
import threading
from dataclasses import dataclass
from typing import Any, Mapping
import paho.mqtt.client as mqtt
from .config import ControllerConfig
from .contracts import (
PLAYER_COMMAND_QOS,
PLAYER_COMMAND_RETAIN,
PLAYER_STATE_QOS,
PLAYER_STATUS_QOS,
PLAYER_TELEMETRY_QOS,
ROUTER_COMMAND_QOS,
ROUTER_COMMAND_RETAIN,
)
LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class MqttEvent:
kind: str
topic: str | None = None
payload: bytes = b""
qos: int = 0
retain: bool = False
message_id: int | None = None
connection_generation: int | None = None
reason: str | None = None
@dataclass(frozen=True)
class PublishReceipt:
accepted: bool
message_id: int | None = None
reason: str | None = None
class MqttAdapter:
"""Own Paho lifecycle while keeping all domain work off its callbacks."""
def __init__(
self,
config: ControllerConfig,
*,
client: Any | None = None,
events: "queue.Queue[MqttEvent] | None" = None,
):
self.config = config
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._generation_lock = threading.Lock()
self._connection_generation = 0
self._connected = threading.Event()
self._stop_event = threading.Event()
self._lifecycle_lock = threading.Lock()
self._started = False
self._loop_started = False
self._bootstrap_thread: threading.Thread | None = None
self.last_error: str | None = None
self._configure_client()
def _configure_client(self) -> None:
if self.config.mqtt.username is not None:
self.client.username_pw_set(
self.config.mqtt.username,
self.config.mqtt.password,
)
self.client.on_connect = self._on_connect
self.client.on_connect_fail = self._on_connect_fail
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.suppress_exceptions = True
self.client.manual_ack_set(True)
self.client.max_queued_messages_set(self.config.mqtt.command_queue_size)
self.client.reconnect_delay_set(
min_delay=self.config.mqtt.reconnect_min_seconds,
max_delay=self.config.mqtt.reconnect_max_seconds,
)
@property
def connected(self) -> bool:
return self._connected.is_set()
@property
def connection_generation(self) -> int:
with self._generation_lock:
return self._connection_generation
@property
def started(self) -> bool:
with self._lifecycle_lock:
return self._started
def start(self) -> None:
"""Start without requiring the broker or network to be reachable."""
with self._lifecycle_lock:
if self._started:
return
self._started = True
self._stop_event.clear()
try:
self._begin_async_connection()
except (OSError, mqtt.WebsocketConnectionError) 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(
self.config.mqtt.host,
self.config.mqtt.port,
keepalive=self.config.mqtt.keepalive_seconds,
)
with self._lifecycle_lock:
if not self._loop_started:
result = self.client.loop_start()
if result != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"failed to start MQTT network loop: {result}")
self._loop_started = True
def _start_bootstrap_retry(self) -> None:
with self._lifecycle_lock:
if self._bootstrap_thread is not None and self._bootstrap_thread.is_alive():
return
self._bootstrap_thread = threading.Thread(
target=self._bootstrap_retry,
name="mqtt-bootstrap-retry",
daemon=True,
)
self._bootstrap_thread.start()
def _bootstrap_retry(self) -> None:
delay = float(self.config.mqtt.reconnect_min_seconds)
maximum = float(self.config.mqtt.reconnect_max_seconds)
while not self._stop_event.wait(delay):
try:
self._begin_async_connection()
self.last_error = None
return
except (OSError, mqtt.WebsocketConnectionError) as exc:
self.last_error = f"MQTT setup retry failed: {exc}"
LOG.warning("%s", self.last_error)
delay = min(maximum, max(delay * 2, 1.0))
except RuntimeError as exc:
self.last_error = str(exc)
LOG.error("%s", self.last_error)
return
def stop(self) -> None:
with self._lifecycle_lock:
if not self._started:
return
self._started = False
loop_started = self._loop_started
self._loop_started = False
self._stop_event.set()
self._connected.clear()
if loop_started:
try:
self.client.disconnect()
except Exception:
LOG.exception("MQTT disconnect failed")
self.client.loop_stop()
thread = self._bootstrap_thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
@staticmethod
def _reason_success(reason_code: Any) -> bool:
try:
return int(reason_code) == 0
except (TypeError, ValueError):
return reason_code == 0
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)
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",
connection_generation=generation,
)
)
except BaseException:
LOG.exception("unexpected MQTT on_connect failure")
def _on_connect_fail(self, client, userdata) -> None:
self.last_error = (
f"MQTT connection to {self.config.mqtt.host}:{self.config.mqtt.port} failed"
)
LOG.warning("%s; Paho will retry", self.last_error)
def _on_disconnect(
self,
client,
userdata,
disconnect_flags,
reason_code,
properties=None,
) -> None:
self._connected.clear()
try:
self._enqueue(
MqttEvent(
kind="disconnected",
connection_generation=self.connection_generation,
reason=str(reason_code),
)
)
except BaseException:
LOG.exception("unexpected MQTT on_disconnect failure")
def _on_message(self, client, userdata, message) -> None:
try:
self._enqueue(
MqttEvent(
kind="message",
topic=str(message.topic),
payload=bytes(message.payload),
qos=int(getattr(message, "qos", 0)),
retain=bool(getattr(message, "retain", False)),
message_id=getattr(message, "mid", None),
connection_generation=self.connection_generation,
)
)
except BaseException:
LOG.exception("unexpected MQTT on_message failure")
def _enqueue(self, event: MqttEvent) -> bool:
while not self._stop_event.is_set():
try:
self.events.put(event, timeout=0.2)
return True
except queue.Full:
continue
return False
def acknowledge(self, event: MqttEvent) -> bool:
if event.message_id is None or event.qos <= 0:
return True
if event.connection_generation != self.connection_generation:
LOG.warning(
"skipping stale MQTT ack mid=%s from generation=%s",
event.message_id,
event.connection_generation,
)
return False
try:
result = self.client.ack(event.message_id, event.qos)
except BaseException:
LOG.exception("MQTT ack failed mid=%s", event.message_id)
return False
return result == mqtt.MQTT_ERR_SUCCESS
def _publish(
self,
topic: str,
payload: str,
*,
qos: int,
retain: bool,
require_connection: bool,
) -> PublishReceipt:
if require_connection and not self.connected:
return PublishReceipt(False, reason="MQTT is disconnected")
try:
info = self.client.publish(topic, payload, qos=qos, retain=retain)
except BaseException as exc:
LOG.exception("MQTT publish failed topic=%s", topic)
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}")
return PublishReceipt(True, message_id=getattr(info, "mid", None))
@staticmethod
def _json_payload(payload: Mapping[str, Any]) -> str:
return json.dumps(payload, allow_nan=False, separators=(",", ":"))
def publish_player_command(self, payload: Mapping[str, Any]) -> PublishReceipt:
return self._publish(
self.config.topics.player_command,
self._json_payload(payload),
qos=PLAYER_COMMAND_QOS,
retain=PLAYER_COMMAND_RETAIN,
require_connection=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,
)
def publish_legacy(self, topic: str, payload: str, *, retain: bool = True) -> PublishReceipt:
return self._publish(
topic,
payload,
qos=0,
retain=retain,
require_connection=True,
)
def publish_json(
self,
topic: str,
payload: Mapping[str, Any],
*,
qos: int = 0,
retain: bool = False,
) -> PublishReceipt:
return self._publish(
topic,
self._json_payload(payload),
qos=qos,
retain=retain,
require_connection=True,
)
+495
View File
@@ -0,0 +1,495 @@
"""Authoritative Player status/telemetry reduction and operation fencing."""
from __future__ import annotations
import math
import time
import uuid
from dataclasses import dataclass, replace
from typing import Any, Mapping
from .config import ControllerConfig
from .contracts import SLOT_IDS
from .models import PlayerObservation
from .store import ControllerStore
TERMINAL_REASONS = frozenset(
{"stop", "finished", "mpv_error", "sigterm", "sigint", "shutdown"}
)
CONTROL_ACTIONS = frozenset({"play", "stop", "pause", "resume", "toggle"})
@dataclass(frozen=True)
class PlayerOperation:
operation_id: str
generation: int
action: str
slot: int | None
expected_path: str | None
deadline: float
state: str = "awaiting_player"
message: str | None = None
@dataclass(frozen=True)
class PlaybackEffect:
kind: str
slot: int | None = None
operation_id: str | None = None
message: str | None = None
class OperationInProgressError(RuntimeError):
pass
class PlaybackReducer:
"""Apply Player evidence without projecting Controller intent as success."""
def __init__(
self,
config: ControllerConfig,
store: ControllerStore,
*,
monotonic=time.monotonic,
wall_clock=time.time,
operation_timeout_seconds: float = 10.0,
):
self.config = config
self.store = store
self._monotonic = monotonic
self._wall_clock = wall_clock
self.operation_timeout_seconds = operation_timeout_seconds
self._operation_generation = 0
self.pending: PlayerOperation | None = None
self.last_operation: PlayerOperation | None = None
def begin_operation(
self,
action: str,
*,
slot: int | None = None,
expected_path: str | None = None,
) -> PlayerOperation:
if action not in CONTROL_ACTIONS:
raise ValueError(f"unsupported Player action {action!r}")
if isinstance(slot, bool) or (slot is not None and slot not in SLOT_IDS):
raise ValueError("slot must be in 1..10")
if self.pending is not None:
raise OperationInProgressError(
f"Player operation {self.pending.operation_id} is still pending"
)
self._operation_generation += 1
operation = PlayerOperation(
operation_id=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,
"pending_play",
guard_id=f"operation-{operation.operation_id}",
details={"operation_id": operation.operation_id},
)
return operation
def publish_failed(self, reason: str) -> PlaybackEffect | None:
if self.pending is None:
return None
return self._finish_operation("failed", reason)
def _finish_operation(self, state: str, message: str | None = None) -> PlaybackEffect:
assert self.pending is not None
operation = replace(self.pending, state=state, message=message)
self.pending = None
self.last_operation = operation
if operation.action == "play" and operation.slot is not None and state != "confirmed":
self.store.clear_playback_guards(
slot=operation.slot,
kind="pending_play",
reason=f"play operation {state}: {message or ''}".strip(),
)
return PlaybackEffect(
kind=f"operation_{state}",
slot=operation.slot,
operation_id=operation.operation_id,
message=message,
)
@staticmethod
def _slot(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
converted = int(value)
except (TypeError, ValueError, OverflowError):
return None
if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
return None
return converted if converted in SLOT_IDS else None
@staticmethod
def _path(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
def _matches_pending(self, slot: int | None, path: str | None) -> bool:
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
def _active_evidence(
self,
*,
slot: int | None,
path: str | None,
paused: bool | None,
status: Mapping[str, Any] | None = None,
telemetry: Mapping[str, Any] | None = None,
) -> PlayerObservation:
previous = self.store.player_observation()
if slot is None:
slot = previous.active_slot
if path is None:
path = previous.active_path
if paused is None and previous.active_slot == slot:
paused = previous.paused
phase = "paused" if paused is True else "playing"
if slot is not None:
self.store.reconcile_active_guard(
slot,
kind="player_active",
details={"path": path},
)
return self.store.set_player_observation(
availability="online",
phase=phase,
active_slot=slot,
active_path=path,
paused=paused,
reconciliation_state="confirmed",
reconciliation_due_at=None,
status_payload=status,
telemetry_payload=telemetry,
)
def _idle_evidence(
self,
*,
status: Mapping[str, Any] | None = None,
telemetry: Mapping[str, Any] | None = None,
) -> PlayerObservation:
# Persist idle before removing protection. A crash between the two
# steps can leave harmless over-protection, never unsafe under-protection.
observation = self.store.set_player_observation(
availability="online",
phase="idle",
active_slot=None,
active_path=None,
paused=False,
reconciliation_state="clear",
reconciliation_due_at=None,
status_payload=status,
telemetry_payload=telemetry,
)
self.store.clear_playback_guards(reason="fresh Player idle evidence")
return observation
def handle_status(self, payload: Any) -> list[PlaybackEffect]:
if not isinstance(payload, Mapping):
return [PlaybackEffect("warning", message="Player status must be an object")]
state = payload.get("state")
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"))
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(
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,
)
effects.append(
PlaybackEffect(
"player_offline",
slot=previous.active_slot,
message="Player availability is offline; playback is unknown",
)
)
return effects
if state == "idle" and payload.get("message") == "player online":
if path is not None:
self._active_evidence(slot=slot, path=path, paused=None, status=payload)
effects.append(
PlaybackEffect(
"player_reconnected_active",
slot=slot,
message="Reconnect idle announcement carried an active path",
)
)
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"))
return effects
if state == "idle":
reason = payload.get("reason")
self._idle_evidence(status=payload)
effects.append(PlaybackEffect("player_idle", slot=slot, message=str(reason or "idle")))
if self.pending is not None:
if self.pending.action == "stop":
effects.append(self._finish_operation("confirmed", str(reason or "idle")))
elif self.pending.action == "play":
effects.append(self._finish_operation("failed", str(reason or "Player remained idle")))
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,
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"))
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,
status=payload,
)
effects.append(
PlaybackEffect(
"control_issued",
slot=slot,
message=f"Player issued {state}; awaiting telemetry",
)
)
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,
status=payload,
)
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
if self.pending is not None and self.pending.action == "play":
effects.append(self._finish_operation("failed", "Player is busy"))
return effects
if state == "error":
previous = self.store.player_observation()
active_slot = slot if slot is not None else previous.active_slot
active_path = path if path is not None else previous.active_path
if active_slot is not None:
self.store.reconcile_active_guard(
active_slot,
kind="player_active",
details={"error": payload.get("message")},
)
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"),
reconciliation_due_at=None,
status_payload=payload,
)
message = str(payload.get("message") or "Player error")
effects.append(PlaybackEffect("player_error", slot=active_slot, message=message))
if self.pending is not None:
effects.append(self._finish_operation("failed", message))
return effects
effects.append(PlaybackEffect("warning", slot=slot, message=f"unknown Player state {state!r}"))
return effects
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"))
effects: list[PlaybackEffect] = []
if path is None:
self._idle_evidence(telemetry=payload)
effects.append(PlaybackEffect("player_idle", slot=slot, message="cleared telemetry"))
if self.pending is not None:
if self.pending.action == "stop":
effects.append(self._finish_operation("confirmed", "cleared telemetry"))
elif self.pending.action == "play":
effects.append(self._finish_operation("failed", "playback cleared"))
return effects
paused_value = payload.get("paused")
paused = paused_value if isinstance(paused_value, bool) else None
observation = self._active_evidence(
slot=slot,
path=path,
paused=paused,
telemetry=payload,
)
effects.append(PlaybackEffect("player_telemetry", slot=observation.active_slot))
operation = self.pending
if operation is None or not self._matches_pending(slot, path):
return effects
if operation.action == "play":
effects.append(self._finish_operation("confirmed", "matching telemetry"))
elif operation.action == "pause" and paused is True:
effects.append(self._finish_operation("confirmed", "telemetry paused=true"))
elif operation.action == "resume" and paused is False:
effects.append(self._finish_operation("confirmed", "telemetry paused=false"))
elif operation.action == "toggle" and paused is not None:
effects.append(self._finish_operation("confirmed", f"telemetry paused={paused}"))
return effects
def check_timeout(self) -> PlaybackEffect | None:
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,
)
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(
previous.active_slot,
kind="restart_uncertain",
review_after=due,
details={"operation_id": operation.operation_id, "reason": "stop timeout"},
)
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_due_at=due,
)
return self._finish_operation("timed_out", "no authoritative Player confirmation")
def projected_slot_state(self, slot: int) -> str:
record = self.store.get_slot(slot)
if record.media_state != "ready":
return record.media_state
observation = self.store.player_observation()
if observation.active_slot == slot:
if observation.reconciliation_state == "operator_review":
return "operator_review"
if observation.phase == "paused" and observation.paused is True:
return "paused"
if observation.phase == "playing":
return "playing"
if observation.phase in {"unknown", "error"}:
return "unknown"
guard = next(
(guard for guard in self.store.list_live_guards() if guard.slot == slot),
None,
)
if guard is not None:
if guard.kind == "pending_play":
return "ready"
return "operator_review" if guard.state == "review_required" else "unknown"
return "ready"
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
due = self.store.mark_due_guards_for_review(self._wall_clock())
if not due:
return []
observation = self.store.player_observation()
if observation.active_slot is not None and any(
guard.slot == observation.active_slot for guard in due
):
self.store.set_player_observation(
availability=observation.availability,
phase="unknown",
active_slot=observation.active_slot,
active_path=observation.active_path,
paused=observation.paused,
reconciliation_state="operator_review",
reconciliation_due_at=observation.reconciliation_due_at,
)
return [
PlaybackEffect(
"operator_review",
slot=guard.slot,
message=(
"Playback remains unknown and protected; fresh Player evidence "
"or an administrative clear is required"
),
)
for guard in due
]
+73
View File
@@ -1060,6 +1060,79 @@ class ControllerStore:
).fetchall() ).fetchall()
return [self._row_to_guard(row) for row in rows] return [self._row_to_guard(row) for row in rows]
def reconcile_active_guard(
self,
slot: int,
*,
kind: str = "player_active",
review_after: float | None = None,
details: Mapping[str, Any] | None = None,
) -> SlotGuard:
"""Replace all uncertain/pending guards with one authoritative guard."""
if slot not in SLOT_IDS:
raise ValueError("slot must be in 1..10")
with self._transaction() as connection:
now = self._now()
connection.execute(
"""
UPDATE slot_guards
SET state = 'cleared', cleared_at = ?, cleared_by = 'player',
clear_reason = 'reconciled by fresh active evidence'
WHERE state IN ('active','review_required')
""",
(now,),
)
guard_id = uuid.uuid4().hex
connection.execute(
"""
INSERT INTO slot_guards
(guard_id, slot_number, kind, state, created_at,
review_after, details_json)
VALUES (?, ?, ?, 'active', ?, ?, ?)
""",
(
guard_id,
slot,
kind,
now,
review_after,
json.dumps(details or {}, sort_keys=True),
),
)
row = connection.execute(
"SELECT * FROM slot_guards WHERE guard_id = ?", (guard_id,)
).fetchone()
assert row is not None
return self._row_to_guard(row)
def clear_playback_guards(
self,
*,
reason: str,
slot: int | None = None,
kind: str | None = None,
) -> int:
clauses = ["state IN ('active','review_required')"]
parameters: list[Any] = []
if slot is not None:
clauses.append("slot_number = ?")
parameters.append(slot)
if kind is not None:
clauses.append("kind = ?")
parameters.append(kind)
with self._transaction() as connection:
now = self._now()
result = connection.execute(
f"""
UPDATE slot_guards
SET state = 'cleared', cleared_at = ?, cleared_by = 'controller',
clear_reason = ?
WHERE {' AND '.join(clauses)}
""",
(now, reason, *parameters),
)
return result.rowcount
def mark_due_guards_for_review(self, now: float | None = None) -> list[SlotGuard]: def mark_due_guards_for_review(self, now: float | None = None) -> list[SlotGuard]:
now = self._now() if now is None else now now = self._now() if now is None else now
with self._transaction() as connection: with self._transaction() as connection:
+201
View File
@@ -0,0 +1,201 @@
import json
import queue
import unittest
from types import SimpleNamespace
import paho.mqtt.client as mqtt
from controller.config import ControllerConfig, MqttConfig
from controller.mqtt_adapter import MqttAdapter
class FakeClient:
def __init__(self):
self.calls = []
self.published = []
self.subscriptions = []
self.acks = []
self.suppress_exceptions = False
self.connect_error = None
def username_pw_set(self, username, password):
self.calls.append(("username_pw_set", username, password))
def manual_ack_set(self, enabled):
self.calls.append(("manual_ack_set", enabled))
def max_queued_messages_set(self, count):
self.calls.append(("max_queued_messages_set", count))
def reconnect_delay_set(self, **kwargs):
self.calls.append(("reconnect_delay_set", kwargs))
def connect_async(self, *args, **kwargs):
self.calls.append(("connect_async", args, kwargs))
if self.connect_error is not None:
raise self.connect_error
return mqtt.MQTT_ERR_SUCCESS
def connect(self, *args, **kwargs):
self.calls.append(("connect", args, kwargs))
raise AssertionError("synchronous connect must never be used")
def loop_start(self):
self.calls.append(("loop_start",))
return mqtt.MQTT_ERR_SUCCESS
def loop_stop(self):
self.calls.append(("loop_stop",))
def disconnect(self):
self.calls.append(("disconnect",))
def subscribe(self, topic, qos=0):
self.subscriptions.append((topic, qos))
return mqtt.MQTT_ERR_SUCCESS, len(self.subscriptions)
def publish(self, topic, payload, qos=0, retain=False):
self.published.append((topic, payload, qos, retain))
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published))
def ack(self, message_id, qos):
self.acks.append((message_id, qos))
return mqtt.MQTT_ERR_SUCCESS
class MqttAdapterTests(unittest.TestCase):
def setUp(self):
self.client = FakeClient()
self.events = queue.Queue()
self.config = ControllerConfig(
mqtt=MqttConfig(
host="broker",
port=1884,
username="user",
password="pass",
client_id="controller-test",
reconnect_min_seconds=1,
reconnect_max_seconds=2,
command_queue_size=8,
)
)
self.adapter = MqttAdapter(
self.config,
client=self.client,
events=self.events,
)
def tearDown(self):
self.adapter.stop()
def test_start_is_asynchronous_and_configures_manual_ack(self):
self.adapter.start()
names = [call[0] for call in self.client.calls]
self.assertIn("manual_ack_set", names)
self.assertIn("connect_async", names)
self.assertIn("loop_start", names)
self.assertNotIn("connect", names)
connect = next(call for call in self.client.calls if call[0] == "connect_async")
self.assertEqual(connect[1], ("broker", 1884))
self.assertEqual(connect[2], {"keepalive": 30})
def test_initial_setup_oserror_does_not_escape_startup(self):
self.client.connect_error = OSError("network unreachable")
self.adapter.start()
self.assertTrue(self.adapter.started)
self.assertIn("network unreachable", self.adapter.last_error)
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
def test_connect_subscribes_to_all_authoritative_inputs(self):
self.adapter.start()
self.adapter._on_connect(self.client, None, None, 0)
self.assertTrue(self.adapter.connected)
self.assertEqual(
self.client.subscriptions,
[
("youtube/player/status", 1),
("youtube/player/telemetry", 0),
("youtube/player/state", 0),
("youtube/controller/cmd", 1),
("audio/player/mode/state", 1),
],
)
event = self.events.get_nowait()
self.assertEqual(event.kind, "connected")
self.assertEqual(event.connection_generation, 1)
def test_player_commands_are_exact_qos_one_and_nonretained(self):
disconnected = self.adapter.publish_player_command({"action": "stop"})
self.assertFalse(disconnected.accepted)
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
receipt = self.adapter.publish_player_command(
{"action": "play", "slot": 3, "label": "Opening"}
)
self.assertTrue(receipt.accepted)
topic, payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, "youtube/player/cmd")
self.assertEqual(json.loads(payload), {"action": "play", "slot": 3, "label": "Opening"})
self.assertEqual(qos, 1)
self.assertIs(retain, False)
def test_router_commands_are_qos_one_nonretained_plain_strings(self):
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
receipt = self.adapter.publish_router_mode("video")
self.assertTrue(receipt.accepted)
self.assertEqual(
self.client.published[-1],
("audio/player/mode/set", "video", 1, False),
)
def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self):
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
topic="youtube/controller/cmd",
payload=b'{"action":"stop"}',
qos=1,
retain=False,
mid=42,
)
self.adapter._on_message(self.client, None, message)
event = self.events.get_nowait()
self.assertEqual(self.client.acks, [])
self.assertEqual(event.message_id, 42)
self.assertTrue(self.adapter.acknowledge(event))
self.assertEqual(self.client.acks, [(42, 1)])
def test_stale_mid_from_previous_connection_is_not_acked(self):
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
topic="youtube/controller/cmd",
payload=b'{"action":"stop"}',
qos=1,
retain=False,
mid=7,
)
self.adapter._on_message(self.client, None, message)
old_event = self.events.get_nowait()
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
self.assertFalse(self.adapter.acknowledge(old_event))
self.assertEqual(self.client.acks, [])
def test_retained_flag_is_preserved_for_coordinator_rejection(self):
self.adapter._on_connect(self.client, None, None, 0)
self.events.get_nowait()
message = SimpleNamespace(
topic="youtube/controller/cmd",
payload=b'{"action":"delete_slot","slot":1}',
qos=1,
retain=True,
mid=8,
)
self.adapter._on_message(self.client, None, message)
self.assertIs(self.events.get_nowait().retain, True)
if __name__ == "__main__":
unittest.main()
+235
View File
@@ -0,0 +1,235 @@
import tempfile
import unittest
from pathlib import Path
from controller.config import ControllerConfig, StorageConfig
from controller.playback import OperationInProgressError, PlaybackReducer
from controller.store import ControllerStore
class Clock:
def __init__(self, value):
self.value = value
def __call__(self):
return self.value
class PlaybackReducerTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
base = Path(self.tempdir.name)
self.wall = Clock(1_700_000_000.0)
self.monotonic = Clock(100.0)
self.config = ControllerConfig(
storage=StorageConfig(
media_base=base / "media",
data_base=base / "data",
retention_seconds=100,
uncertain_review_after_seconds=30,
)
)
self.store = ControllerStore(self.config.storage, now=self.wall).open()
self.reducer = PlaybackReducer(
self.config,
self.store,
monotonic=self.monotonic,
wall_clock=self.wall,
operation_timeout_seconds=5,
)
self._ready(1, "one")
self._ready(2, "two")
def tearDown(self):
self.store.close()
self.tempdir.cleanup()
def _ready(self, slot, suffix):
reservation = self.store.reserve_download(
f"https://example.test/{suffix}", None, None, job_id=f"job-{suffix}"
)
self.assertEqual(reservation.slot, slot)
self.store.mark_download_running(
reservation.job_id, reservation.slot, reservation.generation
)
self.store.complete_download(
reservation.job_id,
reservation.slot,
reservation.generation,
title=suffix.title(),
items=((suffix.title(), f"{suffix}.mp4"),),
)
def test_play_intent_is_not_optimistically_reported_playing(self):
operation = self.reducer.begin_operation(
"play",
slot=1,
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
)
self.assertEqual(operation.state, "awaiting_player")
self.assertEqual(self.store.player_observation().phase, "unknown")
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
with self.assertRaises(OperationInProgressError):
self.reducer.begin_operation("stop")
def test_matching_player_playing_confirms_play_and_protects_slot(self):
self.reducer.begin_operation(
"play",
slot=1,
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
)
effects = self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
}
)
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
self.assertIsNone(self.reducer.pending)
self.assertEqual(self.store.player_observation().phase, "playing")
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
guards = self.store.list_live_guards()
self.assertEqual([(guard.slot, guard.kind) for guard in guards], [(1, "player_active")])
def test_reconnect_online_idle_with_path_is_not_terminal(self):
self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
}
)
effects = self.reducer.handle_status(
{
"state": "idle",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
"message": "player online",
}
)
self.assertEqual(effects[0].kind, "player_reconnected_active")
self.assertEqual(self.store.player_observation().phase, "playing")
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
def test_reconnect_online_idle_without_path_is_fresh_idle_evidence(self):
self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
}
)
self.reducer.handle_status({"state": "idle", "message": "player online"})
self.assertEqual(self.store.player_observation().phase, "idle")
self.assertEqual(self.store.list_live_guards(), [])
def test_offline_without_slot_preserves_active_protection_as_unknown(self):
self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
}
)
effects = self.reducer.handle_status({"state": "offline"})
observation = self.store.player_observation()
self.assertEqual(effects[0].kind, "player_offline")
self.assertEqual(observation.active_slot, 1)
self.assertEqual(observation.phase, "unknown")
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
self.assertEqual(self.store.list_live_guards()[0].kind, "restart_uncertain")
def test_busy_identifies_existing_playback_not_requested_slot(self):
self.reducer.handle_status(
{
"state": "playing",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
}
)
self.reducer.begin_operation(
"play",
slot=2,
expected_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
)
effects = self.reducer.handle_status(
{
"state": "busy",
"slot": 1,
"path": "/mnt/Media/YTPlayer/slot1/playlist.m3u",
"message": "mpv already playing; stop first",
}
)
self.assertIn("operation_failed", [effect.kind for effect in effects])
self.assertEqual(self.store.player_observation().active_slot, 1)
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
self.assertEqual(self.reducer.projected_slot_state(2), "ready")
def test_pause_status_does_not_override_actual_telemetry(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
)
self.reducer.begin_operation("pause", slot=1, expected_path=path)
effects = self.reducer.handle_status(
{"state": "paused", "slot": 1, "path": path}
)
self.assertIn("control_issued", [effect.kind for effect in effects])
self.assertIsNotNone(self.reducer.pending)
self.assertEqual(self.reducer.projected_slot_state(1), "playing")
effects = self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": path, "paused": True}
)
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
self.assertEqual(self.reducer.projected_slot_state(1), "paused")
def test_terminal_idle_and_cleared_telemetry_release_protection(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": path, "paused": False}
)
self.reducer.begin_operation("stop", slot=1, expected_path=path)
effects = self.reducer.handle_telemetry(
{"state": "telemetry", "slot": 1, "path": None, "paused": False}
)
self.assertIn("operation_confirmed", [effect.kind for effect in effects])
self.assertEqual(self.store.player_observation().phase, "idle")
self.assertEqual(self.store.list_live_guards(), [])
self.reducer.handle_status({"state": "idle", "slot": 1, "reason": "stop"})
self.assertEqual(self.reducer.projected_slot_state(1), "ready")
def test_error_does_not_release_owned_playback(self):
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
self.reducer.handle_status(
{"state": "error", "slot": 1, "path": path, "message": "cannot stop"}
)
self.assertEqual(self.store.player_observation().phase, "error")
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
def test_play_timeout_becomes_unknown_then_operator_review_without_unprotecting(self):
self.reducer.begin_operation(
"play",
slot=1,
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
)
self.monotonic.value += 6
effect = self.reducer.check_timeout()
self.assertEqual(effect.kind, "operation_timed_out")
self.assertEqual(self.reducer.projected_slot_state(1), "unknown")
self.wall.value += 31
review = self.reducer.mark_due_reconciliation()
self.assertEqual(review[0].kind, "operator_review")
self.assertEqual(self.reducer.projected_slot_state(1), "operator_review")
self.assertEqual(self.store.list_live_guards()[0].state, "review_required")
def test_malformed_and_unknown_statuses_are_isolated(self):
for payload in (None, [], {}, {"state": "mystery"}):
effects = self.reducer.handle_status(payload)
self.assertEqual(effects[0].kind, "warning")
if __name__ == "__main__":
unittest.main()