From eba096bdc62046653714eccfb0aa3cb15fe1e4c5 Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Tue, 6 Oct 2026 01:02:05 +0200 Subject: [PATCH 1/5] mobile_push: Android commands and TTS as calls of their own The companion app takes notification commands and TTS as the message of a call (message: command_dnd + data.command, message: TTS + tts_text), not as notification data, so mobile_push_command_* went nowhere and mobile_push_tts_text only showed text. Commands now go just before the notification and TTS just after it (on the alarm stream for critical), to Android phones only. Tried on a Samsung S26 Ultra. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHWk2kktg8aTN36n3PKVr --- .../supernotify/transports/mobile_push.py | 67 ++++++++++----- docs/transports/mobile_push.md | 22 +++++ .../transports/test_transport_mobile_push.py | 82 +++++++++++++++++-- 3 files changed, 146 insertions(+), 25 deletions(-) diff --git a/custom_components/supernotify/transports/mobile_push.py b/custom_components/supernotify/transports/mobile_push.py index 75a84e7bb..247db9e40 100644 --- a/custom_components/supernotify/transports/mobile_push.py +++ b/custom_components/supernotify/transports/mobile_push.py @@ -29,15 +29,20 @@ mobile_push_notification_tag str Notification tag for replacement (iOS) / grouping (Android) mobile_push_clear_notification bool Send clear_notification to dismiss previous same-tag notification. Requires push_notification_tag to be set. - mobile_push_tts_text str Android TTS text read aloud on device (Android 8+). - If omitted, push TTS is not activated. + mobile_push_tts_text str Android: text read aloud by the phone (Android 8+), sent as its own + `message: TTS` call after the notification, on the alarm stream + for critical. If omitted, push TTS is not activated. mobile_push_tts_locale str BCP-47 language for TTS (e.g. "it-IT", "en-US"). Only used when push_tts_text is set. mobile_push_tts_engine str TTS engine package (e.g. "com.google.android.tts"). Only used when push_tts_text is set. - mobile_push_command_screen_on bool Android: turn on device screen on delivery (Android 8+) - mobile_push_command_dnd str Android: change Do Not Disturb ("toggle","off","on") - mobile_push_command_ringer_mode str Android: change ringer mode ("silent","vibrate","normal") + mobile_push_command_screen_on bool Android: turn on device screen on delivery (Android 8+), + or "keep_screen_on" + mobile_push_command_dnd str Android: change Do Not Disturb ("alarms_only","priority_only", + "total_silence","off") + mobile_push_command_ringer_mode str Android: change ringer mode ("normal","silent","vibrate") + The command_* keys are each sent as their own `message: command_...` + call before the notification, as the companion app takes them. mobile_push_channel_override str Android notification channel override (e.g. "alarm","general") mobile_push_alarm_stream bool Android: use the alarm_stream channel, which sounds through Do Not Disturb and silent mode @@ -243,22 +248,32 @@ def _android_payload( elif critical: android_data["priority"] = ANDROID_CRITICAL_PRIORITY - # Android TTS: read message aloud on device (Android 8+) + return android_data + + def _android_extra_calls( + self, push_data: dict[str, Any], priority: str | None + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Notification commands and TTS, which the companion app takes as the `message` of a call of + their own rather than as notification data - commands to go before the notification, so the + screen is on or Do Not Disturb off when it arrives, and TTS after it.""" + before: list[dict[str, Any]] = [] + for key in ("command_dnd", "command_ringer_mode", "command_screen_on"): + value = push_data[key] + if value: + before.append({"message": key, ATTR_DATA: {} if value is True else {"command": value}}) + after: list[dict[str, Any]] = [] if push_data["tts_text"]: - android_data["tts_text"] = push_data["tts_text"] + tts_data: dict[str, Any] = {"tts_text": push_data["tts_text"]} + if push_data["alarm_stream_max"]: + tts_data["media_stream"] = "alarm_stream_max" + elif push_data["alarm_stream"] or priority == const.PRIORITY_CRITICAL: + tts_data["media_stream"] = ANDROID_ALARM_STREAM_CHANNEL if push_data["tts_locale"]: - android_data["tts_text_language"] = push_data["tts_locale"] + tts_data["tts_text_language"] = push_data["tts_locale"] if push_data["tts_engine"]: - android_data["tts_engine"] = push_data["tts_engine"] - - # Notification Commands (Android 8+) - if push_data["command_screen_on"]: - android_data["command_screen_on"] = True - if push_data["command_dnd"]: - android_data["command_dnd"] = push_data["command_dnd"] - if push_data["command_ringer_mode"]: - android_data["command_ringer_mode"] = push_data["command_ringer_mode"] - return android_data + tts_data["tts_engine"] = push_data["tts_engine"] + after.append({"message": "TTS", ATTR_DATA: tts_data}) + return before, after async def action_title(self, url: str, retry_timeout: int = 900) -> str | None: """Attempt to create a title for mobile action from the TITLE of the web page at the URL""" @@ -328,6 +343,7 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non # 5. Android-specific fields android_data: dict[str, Any] = self._android_payload(push_data, envelope.priority, channel_given="channel" in raw_data) + android_before, android_after = self._android_extra_calls(push_data, envelope.priority) # 6. Cross-platform: notification tag notification_tag = push_data["notification_tag"] @@ -415,13 +431,22 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non # (e.g. an Android target with no android/ios fields to merge in), and that must # not carry over and clobber the next target's action_data target_data = dict(data) + is_android: bool = mobile_info is not None and mobile_info.manufacturer != MANUFACTURER_APPLE if mobile_info is None: target_data.update(android_data) target_data.update(ios_data) - elif mobile_info.manufacturer != MANUFACTURER_APPLE: # TODO Make this os_name based + if android_before or android_after: + # an iPhone would show a command or TTS call as a notification saying "command_dnd" + _LOGGER.debug( + "SUPERNOTIFY mobile_push: not sending Android commands/TTS to unknown device %s", mobile_target + ) + elif is_android: # TODO Make this os_name based target_data.update(android_data) else: target_data.update(ios_data) + if is_android: + for extra in android_before: + await self.call_action(envelope, qualified_action=full_target, action_data=dict(extra), implied_target=True) action_data = envelope.core_action_data() action_data[ATTR_DATA] = target_data @@ -439,6 +464,10 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non envelope, qualified_action=full_target, action_data=action_data, implied_target=True ) + if success and is_android: + for extra in android_after: + await self.call_action(envelope, qualified_action=full_target, action_data=dict(extra), implied_target=True) + if success: hits += 1 else: diff --git a/docs/transports/mobile_push.md b/docs/transports/mobile_push.md index 548965975..663c8f69d 100644 --- a/docs/transports/mobile_push.md +++ b/docs/transports/mobile_push.md @@ -77,6 +77,28 @@ one, or `false` leaves the channel alone, and `mobile_push_critical_priority` ov Some phones (Samsung One UI for one) also need the Home Assistant app allowed as an exception to Do Not Disturb, in the phone's own settings. +## Android Commands and Spoken Text + +The companion app takes [notification commands](https://companion.home-assistant.io/docs/notifications/notification-commands) +and TTS as the `message` of a call of their own, so Supernotify sends them that way, to Android phones only: +`mobile_push_command_dnd` (`alarms_only`, `priority_only`, `total_silence`, `off`), +`mobile_push_command_ringer_mode` (`normal`, `silent`, `vibrate`) and `mobile_push_command_screen_on` each +go just before the notification, and `mobile_push_tts_text` just after it, on the alarm stream for a +`critical` notification: + +```yaml + - action: supernotify.notify + data: + message: Water leak under the sink + priority: critical + extra_data: + mobile_push_command_screen_on: true + mobile_push_tts_text: Water leak under the sink +``` + +!!! note + `command_dnd` needs a permission the companion app asks for the first time it's used. + ## Notification Grouping Set `mobile_push_group` in `extra_data:` to visually stack notifications together on the device diff --git a/tests/components/supernotify/transports/test_transport_mobile_push.py b/tests/components/supernotify/transports/test_transport_mobile_push.py index a0c81eb99..1f43083c9 100644 --- a/tests/components/supernotify/transports/test_transport_mobile_push.py +++ b/tests/components/supernotify/transports/test_transport_mobile_push.py @@ -574,12 +574,9 @@ async def test_deliver_android_and_ios_extra_data_keys() -> None: assert "alarm_stream_max" not in data assert data["ttl"] == 30 assert data["priority"] == "high" - assert data["tts_text"] == "speak this" - assert data["tts_text_language"] == "en-US" - assert data["tts_engine"] == "com.google.android.tts" - assert data["command_screen_on"] is True - assert data["command_dnd"] == "off" - assert data["command_ringer_mode"] == "silent" + # commands and TTS are calls of their own, and an unknown device might be an iPhone, so none here + assert not any(k.startswith(("tts_", "command_")) for k in data) + assert len(e.calls) == 1 assert data["subtitle"] == "a subtitle" assert data["tag"] == "my-tag" @@ -775,3 +772,76 @@ async def test_android_critical_sounds_through_do_not_disturb( sent = e.calls[0].action_data.get("data", {}) # type: ignore[union-attr] assert {k: sent[k] for k in ("ttl", "priority", "channel") if k in sent} == expected assert not any(k.startswith("mobile_push_") for k in sent) + + +async def _deliver_to(context: Context, manufacturer: str, priority: str, data: dict[str, Any]) -> Envelope: + uut = MobilePushTransport(context) + context.hass_api.mobile_app_by_id.return_value = TrackedDeviceDetails("id001", manufacturer=manufacturer, model="Phone") # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + context.configure_for_tests([uut]) + await context.initialize() + e = Envelope( + Delivery("default", {CONF_TRANSPORT: TRANSPORT_MOBILE_PUSH}, uut), + Notification(context, message="smoke", title="alarm", action_data={ATTR_PRIORITY: priority}), + target=Target({"mobile_app_id": ["mobile_app_phone"]}), + data=data, + ) + await uut.deliver(e) + return e + + +async def test_android_commands_go_before_the_notification_as_their_own_calls(unmocked_config: Context) -> None: + e = await _deliver_to( + unmocked_config, + "Samsung", + PRIORITY_MEDIUM, + { + "mobile_push_command_dnd": "off", + "mobile_push_command_ringer_mode": "normal", + "mobile_push_command_screen_on": True, + }, + ) + + sent = [c.action_data for c in e.calls] + assert sent[:3] == [ + {"message": "command_dnd", "data": {"command": "off"}}, + {"message": "command_ringer_mode", "data": {"command": "normal"}}, + {"message": "command_screen_on", "data": {}}, + ] + assert sent[3]["message"] == "smoke" # type: ignore[index] + assert len(sent) == 4 + assert all(c.action == "mobile_app_phone" for c in e.calls) + + +@pytest.mark.parametrize( + ("priority", "data", "media_stream"), + [ + (PRIORITY_MEDIUM, {}, None), + (PRIORITY_CRITICAL, {}, "alarm_stream"), + (PRIORITY_MEDIUM, {"mobile_push_alarm_stream_max": True}, "alarm_stream_max"), + ], +) +async def test_android_tts_is_its_own_call_after_the_notification( + unmocked_config: Context, priority: str, data: dict[str, Any], media_stream: str | None +) -> None: + e = await _deliver_to(unmocked_config, "Samsung", priority, {"mobile_push_tts_text": "smoke in the kitchen", **data}) + + assert len(e.calls) == 2 + assert e.calls[0].action_data["message"] == "smoke" # type: ignore[index] + assert "tts_text" not in e.calls[0].action_data.get("data", {}) # type: ignore[union-attr] + tts = e.calls[1].action_data + assert tts is not None + assert tts["message"] == "TTS" + assert tts["data"]["tts_text"] == "smoke in the kitchen" + assert tts["data"].get("media_stream") == media_stream + + +async def test_iphone_gets_no_android_commands_or_tts(unmocked_config: Context) -> None: + e = await _deliver_to( + unmocked_config, + "Apple", + PRIORITY_CRITICAL, + {"mobile_push_command_dnd": "off", "mobile_push_tts_text": "smoke in the kitchen"}, + ) + + assert len(e.calls) == 1 + assert e.calls[0].action_data["message"] == "smoke" # type: ignore[index] From e7ea6224a51fc130b00a7f1437abd7fbfd8fde28 Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Tue, 6 Oct 2026 09:50:41 +0200 Subject: [PATCH 2/5] mobile_push: TTS for critical at full alarm volume Tried on a Galaxy Watch: TTS on alarm_stream was too quiet to hear, alarm_stream_max was heard. The companion app puts the volume back afterwards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHWk2kktg8aTN36n3PKVr --- .../supernotify/transports/mobile_push.py | 10 ++++++---- docs/transports/mobile_push.md | 5 +++-- .../transports/test_transport_mobile_push.py | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/custom_components/supernotify/transports/mobile_push.py b/custom_components/supernotify/transports/mobile_push.py index 247db9e40..5c210fbb4 100644 --- a/custom_components/supernotify/transports/mobile_push.py +++ b/custom_components/supernotify/transports/mobile_push.py @@ -30,8 +30,8 @@ mobile_push_clear_notification bool Send clear_notification to dismiss previous same-tag notification. Requires push_notification_tag to be set. mobile_push_tts_text str Android: text read aloud by the phone (Android 8+), sent as its own - `message: TTS` call after the notification, on the alarm stream - for critical. If omitted, push TTS is not activated. + `message: TTS` call after the notification, on the alarm stream at + full volume for critical. If omitted, push TTS is not activated. mobile_push_tts_locale str BCP-47 language for TTS (e.g. "it-IT", "en-US"). Only used when push_tts_text is set. mobile_push_tts_engine str TTS engine package (e.g. "com.google.android.tts"). @@ -264,9 +264,11 @@ def _android_extra_calls( after: list[dict[str, Any]] = [] if push_data["tts_text"]: tts_data: dict[str, Any] = {"tts_text": push_data["tts_text"]} - if push_data["alarm_stream_max"]: + if push_data["alarm_stream_max"] or priority == const.PRIORITY_CRITICAL: + # at full volume for critical - on a watch the alarm volume can be too low to hear, + # and the companion app puts the volume back afterwards tts_data["media_stream"] = "alarm_stream_max" - elif push_data["alarm_stream"] or priority == const.PRIORITY_CRITICAL: + elif push_data["alarm_stream"]: tts_data["media_stream"] = ANDROID_ALARM_STREAM_CHANNEL if push_data["tts_locale"]: tts_data["tts_text_language"] = push_data["tts_locale"] diff --git a/docs/transports/mobile_push.md b/docs/transports/mobile_push.md index 663c8f69d..f5cd052ea 100644 --- a/docs/transports/mobile_push.md +++ b/docs/transports/mobile_push.md @@ -83,8 +83,9 @@ The companion app takes [notification commands](https://companion.home-assistant and TTS as the `message` of a call of their own, so Supernotify sends them that way, to Android phones only: `mobile_push_command_dnd` (`alarms_only`, `priority_only`, `total_silence`, `off`), `mobile_push_command_ringer_mode` (`normal`, `silent`, `vibrate`) and `mobile_push_command_screen_on` each -go just before the notification, and `mobile_push_tts_text` just after it, on the alarm stream for a -`critical` notification: +go just before the notification, and `mobile_push_tts_text` just after it - for a `critical` notification +on the alarm stream at full volume (`alarm_stream_max`, the app puts the volume back afterwards), since a +watch's alarm volume can be too low to hear: ```yaml - action: supernotify.notify diff --git a/tests/components/supernotify/transports/test_transport_mobile_push.py b/tests/components/supernotify/transports/test_transport_mobile_push.py index 1f43083c9..63f830085 100644 --- a/tests/components/supernotify/transports/test_transport_mobile_push.py +++ b/tests/components/supernotify/transports/test_transport_mobile_push.py @@ -816,7 +816,8 @@ async def test_android_commands_go_before_the_notification_as_their_own_calls(un ("priority", "data", "media_stream"), [ (PRIORITY_MEDIUM, {}, None), - (PRIORITY_CRITICAL, {}, "alarm_stream"), + (PRIORITY_CRITICAL, {}, "alarm_stream_max"), + (PRIORITY_MEDIUM, {"mobile_push_alarm_stream": True}, "alarm_stream"), (PRIORITY_MEDIUM, {"mobile_push_alarm_stream_max": True}, "alarm_stream_max"), ], ) From 247061541638a72348022371e5f44735fc85fe5e Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Tue, 6 Oct 2026 10:02:38 +0200 Subject: [PATCH 3/5] mobile_push: Android commands and TTS for critical go with high priority Without it the TTS call waited until the phone was unlocked and was then spoken late, alongside the next one. Tried on a Samsung S26 Ultra. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHWk2kktg8aTN36n3PKVr --- custom_components/supernotify/transports/mobile_push.py | 9 +++++++-- .../supernotify/transports/test_transport_mobile_push.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/custom_components/supernotify/transports/mobile_push.py b/custom_components/supernotify/transports/mobile_push.py index 5c210fbb4..409caa847 100644 --- a/custom_components/supernotify/transports/mobile_push.py +++ b/custom_components/supernotify/transports/mobile_push.py @@ -256,14 +256,19 @@ def _android_extra_calls( """Notification commands and TTS, which the companion app takes as the `message` of a call of their own rather than as notification data - commands to go before the notification, so the screen is on or Do Not Disturb off when it arrives, and TTS after it.""" + # a call without high priority can wait until the phone is unlocked - a critical's TTS would then be + # spoken late, maybe twice - so for critical these go as fast as the notification itself + urgent: dict[str, Any] = ( + {"ttl": ANDROID_CRITICAL_TTL, "priority": ANDROID_CRITICAL_PRIORITY} if priority == const.PRIORITY_CRITICAL else {} + ) before: list[dict[str, Any]] = [] for key in ("command_dnd", "command_ringer_mode", "command_screen_on"): value = push_data[key] if value: - before.append({"message": key, ATTR_DATA: {} if value is True else {"command": value}}) + before.append({"message": key, ATTR_DATA: ({} if value is True else {"command": value}) | urgent}) after: list[dict[str, Any]] = [] if push_data["tts_text"]: - tts_data: dict[str, Any] = {"tts_text": push_data["tts_text"]} + tts_data: dict[str, Any] = {"tts_text": push_data["tts_text"], **urgent} if push_data["alarm_stream_max"] or priority == const.PRIORITY_CRITICAL: # at full volume for critical - on a watch the alarm volume can be too low to hear, # and the companion app puts the volume back afterwards diff --git a/tests/components/supernotify/transports/test_transport_mobile_push.py b/tests/components/supernotify/transports/test_transport_mobile_push.py index 63f830085..94ee75ddf 100644 --- a/tests/components/supernotify/transports/test_transport_mobile_push.py +++ b/tests/components/supernotify/transports/test_transport_mobile_push.py @@ -834,6 +834,9 @@ async def test_android_tts_is_its_own_call_after_the_notification( assert tts["message"] == "TTS" assert tts["data"]["tts_text"] == "smoke in the kitchen" assert tts["data"].get("media_stream") == media_stream + # a critical's TTS goes as fast as the notification, or it waits for the phone to be unlocked + urgent = {"ttl": 0, "priority": "high"} if priority == PRIORITY_CRITICAL else {} + assert {k: tts["data"][k] for k in ("ttl", "priority") if k in tts["data"]} == urgent async def test_iphone_gets_no_android_commands_or_tts(unmocked_config: Context) -> None: @@ -846,3 +849,9 @@ async def test_iphone_gets_no_android_commands_or_tts(unmocked_config: Context) assert len(e.calls) == 1 assert e.calls[0].action_data["message"] == "smoke" # type: ignore[index] + + +async def test_android_commands_for_critical_go_with_high_priority(unmocked_config: Context) -> None: + e = await _deliver_to(unmocked_config, "Samsung", PRIORITY_CRITICAL, {"mobile_push_command_screen_on": True}) + + assert e.calls[0].action_data == {"message": "command_screen_on", "data": {"ttl": 0, "priority": "high"}} From 1a9ef8c373e4efafd1f6f0d6a0681fba1de044b6 Mon Sep 17 00:00:00 2001 From: Lorenzo Mattioli Date: Tue, 6 Oct 2026 10:12:20 +0200 Subject: [PATCH 4/5] mobile_push: TTS 5 seconds after the notification on Android Started together, the TTS cut off the notification's own sound. One wait for all the devices, mobile_push_tts_delay to change it (0 for straight away). Tried on a Samsung S26 Ultra. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHWk2kktg8aTN36n3PKVr --- .../supernotify/transports/mobile_push.py | 17 +++++++++-- .../transports/test_transport_mobile_push.py | 30 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/custom_components/supernotify/transports/mobile_push.py b/custom_components/supernotify/transports/mobile_push.py index 409caa847..8831399f0 100644 --- a/custom_components/supernotify/transports/mobile_push.py +++ b/custom_components/supernotify/transports/mobile_push.py @@ -32,6 +32,8 @@ mobile_push_tts_text str Android: text read aloud by the phone (Android 8+), sent as its own `message: TTS` call after the notification, on the alarm stream at full volume for critical. If omitted, push TTS is not activated. + mobile_push_tts_delay int Seconds between the notification and its TTS, so the notification's + own sound isn't cut off (default 5, 0 for straight away). mobile_push_tts_locale str BCP-47 language for TTS (e.g. "it-IT", "en-US"). Only used when push_tts_text is set. mobile_push_tts_engine str TTS engine package (e.g. "com.google.android.tts"). @@ -53,6 +55,7 @@ from __future__ import annotations +import asyncio import logging import time from datetime import timedelta @@ -123,6 +126,9 @@ # documents for critical notifications, alarm_stream being what gets through Do Not Disturb ANDROID_CRITICAL_PRIORITY = "high" ANDROID_ALARM_STREAM_CHANNEL = "alarm_stream" +# seconds between a notification and its TTS on Android - started together, the TTS cuts off the +# notification's own sound +ANDROID_TTS_DELAY = 5 class MobilePushTransport(Transport): @@ -204,6 +210,7 @@ def _extract_push_data(self, raw_data: dict[str, Any]) -> dict[str, Any]: "tts_text": raw_data.pop("mobile_push_tts_text", None), "tts_locale": raw_data.pop("mobile_push_tts_locale", None), "tts_engine": raw_data.pop("mobile_push_tts_engine", None), + "tts_delay": raw_data.pop("mobile_push_tts_delay", ANDROID_TTS_DELAY), # Android Notification Commands "command_screen_on": raw_data.pop("mobile_push_command_screen_on", None), "command_dnd": raw_data.pop("mobile_push_command_dnd", None), @@ -426,6 +433,7 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non clear_notification = bool(push_data["clear_notification"] and notification_tag) model_filter = SelectionRule(envelope.delivery.options.get(OPTION_DEVICE_MODEL_SELECT)) hits = 0 + tts_pending: list[tuple[str, dict[str, Any]]] = [] for mobile_target in envelope.target.mobile_app_ids: full_target = mobile_target if Target.is_notify_entity(mobile_target) else f"notify.{mobile_target}" @@ -472,8 +480,7 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non ) if success and is_android: - for extra in android_after: - await self.call_action(envelope, qualified_action=full_target, action_data=dict(extra), implied_target=True) + tts_pending.extend((full_target, dict(extra)) for extra in android_after) if success: hits += 1 @@ -496,4 +503,10 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non snooze_for=timedelta(days=1), reason="Action Failure", ) + if tts_pending: + # one wait for all the devices, after every notification has gone + if push_data["tts_delay"]: + await asyncio.sleep(push_data["tts_delay"]) + for full_target, extra in tts_pending: + await self.call_action(envelope, qualified_action=full_target, action_data=extra, implied_target=True) return hits > 0 diff --git a/tests/components/supernotify/transports/test_transport_mobile_push.py b/tests/components/supernotify/transports/test_transport_mobile_push.py index 94ee75ddf..5f81ea308 100644 --- a/tests/components/supernotify/transports/test_transport_mobile_push.py +++ b/tests/components/supernotify/transports/test_transport_mobile_push.py @@ -824,7 +824,9 @@ async def test_android_commands_go_before_the_notification_as_their_own_calls(un async def test_android_tts_is_its_own_call_after_the_notification( unmocked_config: Context, priority: str, data: dict[str, Any], media_stream: str | None ) -> None: - e = await _deliver_to(unmocked_config, "Samsung", priority, {"mobile_push_tts_text": "smoke in the kitchen", **data}) + with patch("custom_components.supernotify.transports.mobile_push.asyncio.sleep") as sleep: + e = await _deliver_to(unmocked_config, "Samsung", priority, {"mobile_push_tts_text": "smoke in the kitchen", **data}) + sleep.assert_awaited_once_with(5) # so the notification's own sound is heard before the TTS assert len(e.calls) == 2 assert e.calls[0].action_data["message"] == "smoke" # type: ignore[index] @@ -855,3 +857,29 @@ async def test_android_commands_for_critical_go_with_high_priority(unmocked_conf e = await _deliver_to(unmocked_config, "Samsung", PRIORITY_CRITICAL, {"mobile_push_command_screen_on": True}) assert e.calls[0].action_data == {"message": "command_screen_on", "data": {"ttl": 0, "priority": "high"}} + + +async def test_android_tts_waits_once_for_all_devices_and_can_go_straight_away(unmocked_config: Context) -> None: + context = unmocked_config + uut = MobilePushTransport(context) + context.hass_api.mobile_app_by_id.return_value = TrackedDeviceDetails("id001", manufacturer="Samsung", model="Phone") # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + context.configure_for_tests([uut]) + await context.initialize() + + def envelope(data: dict[str, Any]) -> Envelope: + return Envelope( + Delivery("default", {CONF_TRANSPORT: TRANSPORT_MOBILE_PUSH}, uut), + Notification(context, message="smoke", title="alarm", action_data={ATTR_PRIORITY: PRIORITY_CRITICAL}), + target=Target({"mobile_app_id": ["mobile_app_phone", "mobile_app_watch"]}), + data=data, + ) + + with patch("custom_components.supernotify.transports.mobile_push.asyncio.sleep") as sleep: + e = envelope({"mobile_push_tts_text": "smoke"}) + await uut.deliver(e) + sleep.assert_awaited_once_with(5) + assert [c.action_data["message"] for c in e.calls] == ["smoke", "smoke", "TTS", "TTS"] # type: ignore[index] + + with patch("custom_components.supernotify.transports.mobile_push.asyncio.sleep") as sleep: + await uut.deliver(envelope({"mobile_push_tts_text": "smoke", "mobile_push_tts_delay": 0})) + sleep.assert_not_awaited() From 714326e08cbab9df78824b42e6ee208f90c21a5c Mon Sep 17 00:00:00 2001 From: lollo Date: Thu, 8 Oct 2026 23:33:52 +0200 Subject: [PATCH 5/5] docs: mobile_push Android commands and spoken text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XHmtQPskJJiphmnb37zkdR --- docs/transports/mobile_push.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/transports/mobile_push.md b/docs/transports/mobile_push.md index f5cd052ea..07a42b264 100644 --- a/docs/transports/mobile_push.md +++ b/docs/transports/mobile_push.md @@ -83,7 +83,8 @@ The companion app takes [notification commands](https://companion.home-assistant and TTS as the `message` of a call of their own, so Supernotify sends them that way, to Android phones only: `mobile_push_command_dnd` (`alarms_only`, `priority_only`, `total_silence`, `off`), `mobile_push_command_ringer_mode` (`normal`, `silent`, `vibrate`) and `mobile_push_command_screen_on` each -go just before the notification, and `mobile_push_tts_text` just after it - for a `critical` notification +go just before the notification, and `mobile_push_tts_text` 5 seconds after it, so the notification's own +sound isn't cut off (`mobile_push_tts_delay` changes that, `0` for straight away) - for a `critical` notification on the alarm stream at full volume (`alarm_stream_max`, the app puts the volume back afterwards), since a watch's alarm volume can be too low to hear: