423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""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},
|
|
)
|