"""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}, )