Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ helpers unless you want to.
speaker's original volume once the message finishes playing. Muted speakers are
unmuted for the announcement and re-muted afterwards.
- **Sonos-correct** — uses `cache: true`, which Sonos requires (without it Sonos
silently refuses to play HA's on-demand TTS stream).
silently refuses to play HA's on-demand TTS stream), and confirms Sonos
announcements from the speaker's own clip status, including through Music
Assistant.
- **Loud about failure** — a `critical: true` broadcast retries harder and raises
an error if anything did not get through, so an emergency automation cannot
mistake a half-delivered message for a delivered one.
Expand Down Expand Up @@ -171,10 +173,11 @@ event) that says what happened to each target individually:
"duration": 6.4,
"players": [
{ "entity_id": "media_player.sonosroam", "name": "Sonos Roam",
"status": "played", "verified": true, "attempts": 1,
"error": null, "detail": null, "warnings": [] },
"status": "played", "verified": true, "verified_by": "sonos_clip",
"attempts": 1, "error": null, "detail": null, "warnings": [] },
{ "entity_id": "media_player.study", "name": "Study",
"status": "unverified", "verified": false, "attempts": 1,
"status": "unverified", "verified": false, "verified_by": "state",
"attempts": 1,
"error": "the player accepted the command but no playback was detected within 8s",
"detail": null, "warnings": [] }
],
Expand All @@ -197,7 +200,7 @@ event) that says what happened to each target individually:
| `played` | Home Assistant saw the speaker start playing. This is the only status that means sound came out. |
| `unverified` | The speaker accepted the command but never showed any sign of playing. This is the "reported as sent, nothing heard" case. |
| `silent` | The clip played, but into a muted speaker or one at zero volume, so nobody could have heard it. |
| `failed` | The `tts.speak` call itself raised, e.g. Sonos's *"The command to the player failed."* |
| `failed` | The `tts.speak` call itself raised (e.g. Sonos's *"The command to the player failed."*), or the speaker reported that it could not play the clip. |
| `offline` | The entity is missing or `unavailable`; nothing was attempted. |
| `unsupported` | The entity exists but cannot play media at all. |
| `sent` | Only with `verify: false` — dispatched, delivery not confirmed. |
Expand Down Expand Up @@ -261,11 +264,27 @@ choose the level yourself.

### How playback is confirmed

A state-change listener is armed on each speaker **before** `tts.speak` is
called, then playback counts as confirmed if the player enters a playing state,
switches to different media, or (if it was already playing) picks up a new media
duration. Arming first matters: a short clip can start and finish faster than any
polling loop would notice.
Each speaker gets the most specific check available for it, armed **before**
`tts.speak` is called (a short clip can start and finish faster than any polling
loop would notice). The check used is reported per speaker as `verified_by`.

| `verified_by` | Used for | Evidence |
| --- | --- | --- |
| `sonos_clip` | Sonos speakers from HA's Sonos integration, and Music Assistant players backed by them | The speaker's own audio-clip status: the clip goes active then done, or the speaker reports it could not play it. |
| `state` | Every other player | The player enters a playing state, switches to different media, or (if it was already playing) picks up a new media duration. |

Sonos needs its own check because HA plays announcements on Sonos as audio
clips, and the media player entity does not change state while a clip plays.
The Sonos check connects to the speaker's local API (the same one HA's Sonos
integration uses). If it cannot, it quietly falls back to the `state` check.

Checks for other kinds of player can be added by subclassing
`StateVerifier` in `custom_components/intercom/verifiers/`; see the docstring
in `verifiers/base.py`.

If a broadcast is reported as failed but you heard it, or it is reported as
played on a speaker that stayed silent, see
[the troubleshooting guide](docs/troubleshooting.md).

### Sending something that has to get through

Expand Down Expand Up @@ -323,8 +342,9 @@ script that maps toggles to entity lists and calls `intercom.broadcast`, and a

## Limitations

- **Confirmation is state-based, not acoustic.** `played` means the player
reported that it started playing the clip, and that it was neither muted nor
- **Confirmation is reported, not acoustic.** `played` means the player (or,
for Sonos, the speaker itself) reported that it started playing the clip, and
that it was neither muted nor
at zero volume. It cannot catch a speaker whose amplifier is off, whose output
is routed elsewhere, or that is physically unplugged mid-sentence. It does
catch the common cases: rejected commands, muted or silenced speakers, and
Expand All @@ -336,6 +356,15 @@ script that maps toggles to entity lists and calls `intercom.broadcast`, and a
- **Notify delivery isn't confirmed.** A failed notify *service call* is reported
and retried, but Home Assistant cannot confirm a push actually reached a phone.
Treat `sent` as "handed to the notification platform".
- **One Sonos speaker, one entity per broadcast.** Sonos confirmation follows
the first new clip on the speaker. If a broadcast targets the same physical
speaker through two entities (e.g. the native Sonos entity *and* its Music
Assistant player), both results follow the same clip, and the message is
spoken twice anyway. Pick one entity per speaker.
- **Audio that bypasses Sonos clips takes the full start timeout to confirm.**
If a Sonos is fed some other way (e.g. Music Assistant streaming over
AirPlay), the speaker reports no clip. The check then waits out the start
timeout before judging by entity state instead.
- **A speaker in a group may play on its coordinator.** Multi-room groups are
reported per entity; the audio may come out of the group instead.

Expand Down
161 changes: 34 additions & 127 deletions custom_components/intercom/broadcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@
from typing import Any
from uuid import uuid4

from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.core import HomeAssistant, State
from homeassistant.util import dt as dt_util

from .const import (
Expand All @@ -33,7 +32,6 @@
FEATURE_PLAY_MEDIA,
FEATURE_VOLUME_MUTE,
FEATURE_VOLUME_SET,
PLAYING_STATES,
STATUS_FAILED,
STATUS_OFFLINE,
STATUS_PLAYED,
Expand All @@ -44,6 +42,7 @@
UNAVAILABLE_STATES,
VOLUME_SETTLE_SECONDS,
)
from .verifiers import PlaybackVerifier, Verdict, select_verifier

_LOGGER = logging.getLogger(__package__)

Expand Down Expand Up @@ -79,121 +78,6 @@ class _Preparation:
silent_reason: str | None = None


# --- playback verification ----------------------------------------------------


def _looks_like_playback_start(
old: State | None,
new: State,
baseline_content: str | None,
was_playing: bool,
) -> bool:
"""Decide whether a state change is our announcement starting.

Players differ wildly in what they report, so we accept any of three signals:
a transition into a playing state, a switch to different media, or (for a
player that was already playing music) a new media duration.
"""
if new.state in PLAYING_STATES and (old is None or old.state not in PLAYING_STATES):
return True

if new.state in UNAVAILABLE_STATES:
return False

content = new.attributes.get("media_content_id")
if content and content != baseline_content:
return True

if was_playing and new.state in PLAYING_STATES:
duration = new.attributes.get("media_duration")
previous = old.attributes.get("media_duration") if old else None
if duration is not None and duration != previous:
return True

return False


class _PlaybackWatch:
"""Watch one media player for evidence that our announcement played.

Armed *before* ``tts.speak`` is called so nothing is missed, including clips
short enough to begin and end while the service call is still returning.
"""

def __init__(self, hass: HomeAssistant, entity_id: str) -> None:
self._hass = hass
self._entity_id = entity_id
self._unsub: CALLBACK_TYPE | None = None
self._baseline_content: str | None = None
self._was_playing = False
self._playing_content: str | None = None
self.started = asyncio.Event()
self.finished = asyncio.Event()

def arm(self) -> None:
"""Start listening. Call this before asking the player to speak."""
state = self._hass.states.get(self._entity_id)
if state is not None:
self._baseline_content = state.attributes.get("media_content_id")
self._was_playing = state.state in PLAYING_STATES
self._unsub = async_track_state_change_event(
self._hass, [self._entity_id], self._handle
)

def disarm(self) -> None:
"""Stop listening."""
if self._unsub is not None:
self._unsub()
self._unsub = None

@callback
def _handle(self, event: Event) -> None:
new: State | None = event.data.get("new_state")
if new is None:
return

if not self.started.is_set():
if _looks_like_playback_start(
event.data.get("old_state"),
new,
self._baseline_content,
self._was_playing,
):
self._playing_content = new.attributes.get("media_content_id")
self.started.set()
return

# Finished = no longer playing, or moved on to different media (a player
# that resumes the music it interrupted never leaves the playing state).
if new.state not in PLAYING_STATES:
self.finished.set()
return
content = new.attributes.get("media_content_id")
if self._playing_content is not None and content not in (
None,
self._playing_content,
):
self.finished.set()

async def wait_started(self, timeout: float) -> bool:
"""Return True once playback evidence is seen, False on timeout."""
return await _wait_event(self.started, timeout)

async def wait_finished(self, timeout: float) -> bool:
"""Return True once playback has ended, False on timeout."""
return await _wait_event(self.finished, timeout)


async def _wait_event(event: asyncio.Event, timeout: float) -> bool:
"""Wait for an asyncio event, returning False instead of raising on timeout."""
try:
async with asyncio.timeout(timeout):
await event.wait()
except TimeoutError:
return False
return True


# --- speaking -----------------------------------------------------------------


Expand All @@ -217,6 +101,8 @@ def _new_player_outcome(hass: HomeAssistant, entity_id: str) -> dict[str, Any]:
"name": _friendly_name(hass, entity_id),
"status": STATUS_OFFLINE,
"verified": False,
# Which check judged playback (see verifiers/); None if never checked.
"verified_by": None,
"attempts": 0,
"error": None,
"detail": None,
Expand Down Expand Up @@ -378,9 +264,14 @@ async def _async_speak_on_player(
prep = await _async_prepare_player(
hass, request, entity_id, state, features, outcome
)
watch = _PlaybackWatch(hass, entity_id)
watch.arm()
verifier = (
select_verifier(hass, entity_id)
if request.verify
else PlaybackVerifier(hass, entity_id)
)
try:
# Armed before speaking so even the shortest clip is observed.
await verifier.async_arm()
try:
async with asyncio.timeout(request.wait_timeout):
await hass.services.async_call(
Expand All @@ -407,10 +298,10 @@ async def _async_speak_on_player(
outcome["status"] = STATUS_FAILED
outcome["error"] = _error_text(err)
else:
await _async_confirm_playback(request, watch, outcome)
await _async_confirm_playback(request, verifier, outcome)
_apply_silence(prep, outcome)
finally:
watch.disarm()
await verifier.async_disarm()
await _async_restore_player(hass, entity_id, prep.restore, outcome)

if outcome["status"] in DELIVERED_STATUSES:
Expand Down Expand Up @@ -438,15 +329,31 @@ def _apply_silence(prep: _Preparation, outcome: dict[str, Any]) -> None:


async def _async_confirm_playback(
request: BroadcastRequest, watch: _PlaybackWatch, outcome: dict[str, Any]
request: BroadcastRequest,
verifier: PlaybackVerifier,
outcome: dict[str, Any],
) -> None:
"""Turn "the service call returned" into an honest playback status."""
if not request.verify:
verdict = await verifier.async_wait_started(request.start_timeout)
outcome["verified_by"] = verifier.name

if verdict is Verdict.ASSUMED:
outcome["status"] = STATUS_SENT
outcome["detail"] = "verification disabled; playback not confirmed"
outcome["verified_by"] = None
outcome["detail"] = (
"verification disabled; playback not confirmed"
if not request.verify
else "no playback check is available for this player; "
"playback not confirmed"
)
return

if verdict is Verdict.FAILED:
outcome["status"] = STATUS_FAILED
outcome["error"] = verifier.failure or "the player could not play the clip"
return

if not await watch.wait_started(request.start_timeout):
if verdict is Verdict.TIMEOUT:
outcome["status"] = STATUS_UNVERIFIED
outcome["error"] = (
f"the player accepted the command but no playback was detected "
Expand All @@ -457,7 +364,7 @@ async def _async_confirm_playback(
outcome["status"] = STATUS_PLAYED
outcome["verified"] = True
outcome["error"] = None
if not await watch.wait_finished(request.wait_timeout):
if not await verifier.async_wait_finished(request.wait_timeout):
outcome["detail"] = "still playing when the wait timed out"


Expand Down
2 changes: 1 addition & 1 deletion custom_components/intercom/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/will-roscoe/intercom/issues",
"requirements": [],
"version": "0.2.0"
"version": "0.3.0"
}
18 changes: 18 additions & 0 deletions custom_components/intercom/verifiers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Pluggable checks that a media player really played an announcement.

See ``base.py`` for how to add a check for a new kind of player. Importing this
package registers the built-in verifiers.
"""

from .base import PlaybackVerifier, Verdict, select_verifier, wait_event
from .sonos import SonosClipVerifier
from .state import StateVerifier

__all__ = [
"PlaybackVerifier",
"SonosClipVerifier",
"StateVerifier",
"Verdict",
"select_verifier",
"wait_event",
]
Loading
Loading