feat: coordinate authoritative playback and audio routing
This commit is contained in:
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user