From f1b8c18098d9c74def3ce782e0f5ddef95e19f0f Mon Sep 17 00:00:00 2001 From: Jey Burrows Date: Sun, 8 Nov 2026 15:02:26 +0000 Subject: [PATCH 1/2] LG webOS TV transport, and area/floor/label targets in delivery selection A webostv transport, set up automatically with the LG webOS TV integration. Each TV's media_player entity is the target, mapped to the TV's legacy notify action through its config entry, so TVs can be reached directly or by area, floor, label or device. TVs that are off or unavailable are skipped, only the message is shown (the title only when there's no message), and data.icon is passed through. Delivery selection now resolves area_id/floor_id/label_id selectors when checking whether a delivery's target category is present, so an area target selects the transport that owns what's in it - webostv for a TV, alexa_devices for an echo - rather than only the generic notify entity catch-all. Closes #313 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- custom_components/supernotify/const.py | 2 + custom_components/supernotify/hass_api.py | 9 ++ custom_components/supernotify/manifest.json | 1 + custom_components/supernotify/notification.py | 4 +- .../supernotify/static_config.py | 2 + .../supernotify/transports/webostv.py | 103 +++++++++++++ docs/configuration/deliveries.md | 4 +- docs/developer/roadmap.md | 4 + docs/transports/webostv.md | 70 +++++++++ docs/usage/targets.md | 2 +- .../integration/test_integration_webostv.py | 114 ++++++++++++++ .../integration/test_town_house.py | 8 +- .../supernotify/integration/test_zero_yaml.py | 8 +- .../transports/test_transport_webostv.py | 142 ++++++++++++++++++ 15 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 custom_components/supernotify/transports/webostv.py create mode 100644 docs/transports/webostv.md create mode 100644 tests/components/supernotify/integration/test_integration_webostv.py create mode 100644 tests/components/supernotify/transports/test_transport_webostv.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b61632c8f..339a7083a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ # v2.18.0 -This release continues the move of configuration from YAML into the configuration web page - now for *Recipients* and *Transports*. This makes it easy to add an email address for people so they automatically get mailed, pick which mobile devices get included, tune deliveries and include people who aren't Home Assistant users. +This release continues the move of configuration from YAML into the configuration web page - now for *Recipients* and *Transports*. This makes it easy to add an email address for people so they automatically get mailed, pick which mobile devices get included, tune deliveries and include people who aren't Home Assistant users. It also adds WebOS TV notifications. ## 🚨 Breaking changes - *Recipients*: `recipients:` in YAML is imported into the integration at start up, and ignored in the YAML from then on, with a repair asking for it to be removed @@ -19,6 +19,8 @@ This release continues the move of configuration from YAML into the configuratio - *Housekeeping*: a **Deprecated Entities** option removes the deprecated recipient, delivery and transport `binary_sensor` entities an install carried over from an earlier version, along with their repairs - only offered while there are some - *Transports*: a repair names any deprecated settings a transport still has, such as `device_domain` directly on it rather than in its options, and saving the transport in the integration moves them to their replacements - *Conditions*: `notification_actions` and `notification_action_groups` condition variables, holding the `actions` and `action_groups` given on the notify action, so a scenario can send notifications with buttons only to deliveries that can show them - see the [Send Buttons Only Where They Work](https://supernotify.rhizomatics.org.uk/latest/recipes/buttons_where_supported/) recipe ([#311](https://github.com/rhizomatics/supernotify/issues/311)) +- *Transports*: [LG webOS TV](https://supernotify.rhizomatics.org.uk/latest/transports/webostv/) transport, set up automatically with the LG webOS TV integration, showing a toast on each TV targeted directly or by area, floor, label or device, and skipping TVs that are off ([#313](https://github.com/rhizomatics/supernotify/issues/313)) +- *Deliveries*: an `area_id`, `floor_id` or `label_id` target now selects a delivery by what's in it, so an area with an Alexa echo uses `alexa_devices`, rather than the generic notify entity delivery ## 📝 Other changes ### Development diff --git a/custom_components/supernotify/const.py b/custom_components/supernotify/const.py index ff2bf26b08..9edd7ae442 100644 --- a/custom_components/supernotify/const.py +++ b/custom_components/supernotify/const.py @@ -299,6 +299,7 @@ TRANSPORT_MATRIX = "matrix" TRANSPORT_KODI = "kodi" TRANSPORT_DISCORD = "discord" +TRANSPORT_WEBOSTV = "webostv" TRANSPORT_VALUES = [ TRANSPORT_SMS, TRANSPORT_MQTT, @@ -321,6 +322,7 @@ TRANSPORT_MATRIX, TRANSPORT_KODI, TRANSPORT_DISCORD, + TRANSPORT_WEBOSTV, ] # The master list of target category names, independent of any one transport - both diff --git a/custom_components/supernotify/hass_api.py b/custom_components/supernotify/hass_api.py index 427731d6bc..148bda29dd 100644 --- a/custom_components/supernotify/hass_api.py +++ b/custom_components/supernotify/hass_api.py @@ -335,6 +335,15 @@ def config_path(self, path: str) -> str: """A path relative to the Home Assistant config directory, or the path itself if absolute.""" return self._hass.config.path(path) + def config_entry_title_for_entity(self, entity_id: str) -> str | None: + """The title of the config entry that registered this entity, if any.""" + entity_registry: EntityRegistry | None = self._entity_registry() + reg_entry: RegistryEntry | None = entity_registry.async_get(entity_id) if entity_registry else None + if reg_entry is None or reg_entry.config_entry_id is None: + return None + config_entry = self._hass.config_entries.async_get_entry(reg_entry.config_entry_id) + return config_entry.title if config_entry else None + def device_for_entity(self, entity_id: str) -> str | None: """The device this entity belongs to, if any.""" entity_registry: EntityRegistry | None = self._entity_registry() diff --git a/custom_components/supernotify/manifest.json b/custom_components/supernotify/manifest.json index 3f0d90b86a..ee19ebe0de 100644 --- a/custom_components/supernotify/manifest.json +++ b/custom_components/supernotify/manifest.json @@ -24,6 +24,7 @@ "trace", "tts", "twilio_sms", + "webostv", "workday" ], "categories": [ diff --git a/custom_components/supernotify/notification.py b/custom_components/supernotify/notification.py index ce10516cca..4deabc20e9 100644 --- a/custom_components/supernotify/notification.py +++ b/custom_components/supernotify/notification.py @@ -911,10 +911,12 @@ def _category_satisfied(self, categories: list[str | TargetEntityCategory], pool later, so this can be generous (a plain-string category needs a non-empty value list; a `TargetEntityCategory` needs any pool entity_id whose domain/platform matches, group entities expanded first since a bare `group.*` id's own domain never matches anything - - it's the members' domain that counts).""" + it's the members' domain that counts, and area/floor/label selectors resolved to their + entities, so a TV in the targeted area counts).""" if not categories: return False hass_api: HomeAssistantAPI = self.context.hass_api + pool = pool.resolve_selectors(hass_api) entity_ids: list[str] = hass_api.expand_group(pool.entity_ids) if pool.entity_ids else [] for category in categories: if isinstance(category, TargetEntityCategory): diff --git a/custom_components/supernotify/static_config.py b/custom_components/supernotify/static_config.py index 2b02ad4757..c6e06da41c 100644 --- a/custom_components/supernotify/static_config.py +++ b/custom_components/supernotify/static_config.py @@ -20,6 +20,7 @@ from .transports.sms import SMSTransport from .transports.telegram import TelegramTransport from .transports.tts import TTSTransport +from .transports.webostv import WebOsTvTransport TRANSPORTS: list[type[Transport]] = [ EmailTransport, @@ -43,6 +44,7 @@ MatrixTransport, KodiTransport, DiscordTransport, + WebOsTvTransport, ] # No auto-discovery of transport plugins so manual class registration required here TRANSPORT_NAMES = [t.name for t in TRANSPORTS] diff --git a/custom_components/supernotify/transports/webostv.py b/custom_components/supernotify/transports/webostv.py new file mode 100644 index 0000000000..9a6e128ef8 --- /dev/null +++ b/custom_components/supernotify/transports/webostv.py @@ -0,0 +1,103 @@ +"""LG webOS TV transport for SuperNotify. + +Shows a toast on LG webOS TVs set up with Home Assistant's `webostv` integration. That +integration has a legacy notify action for each TV, named after its config entry, such as +`notify.living_room_tv`, rather than a notify entity. So the TV's `media_player` entity is the +target, and each one is mapped to its notify action through its config entry. As an entity, +it can be reached by area, floor, label or device too. + +Only the message is shown: the title is used only when there is no message. + +TVs that are off, or unavailable, are skipped, since the integration can't reach them and +would otherwise raise an error. + +Supported data keys (optional): + icon str path to an image file on the Home Assistant host, shown as the toast's icon +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE +from homeassistant.const import ATTR_ICON, STATE_OFF, STATE_UNAVAILABLE +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify + +from custom_components.supernotify.const import TRANSPORT_WEBOSTV +from custom_components.supernotify.model import DebugTrace, SuppressionReason, TransportFeature +from custom_components.supernotify.target import TargetEntityCategory +from custom_components.supernotify.transport import Transport + +if TYPE_CHECKING: + from custom_components.supernotify.envelope import Envelope + from custom_components.supernotify.hass_api import HomeAssistantAPI + +_LOGGER = logging.getLogger(__name__) + +HA_WEBOSTV_DOMAIN = "webostv" + + +class WebOsTvTransport(Transport): + """Notify via a toast on LG webOS TVs, using the webostv integration""" + + name = TRANSPORT_WEBOSTV + integration_domain = HA_WEBOSTV_DOMAIN + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + @property + def supported_features(self) -> TransportFeature: + return TransportFeature.MESSAGE + + @property + def unique_target_categories(self) -> list[str | TargetEntityCategory]: + return [TargetEntityCategory(domain="media_player", platform=HA_WEBOSTV_DOMAIN)] + + def is_viable(self, hass_api: HomeAssistantAPI) -> bool: + return hass_api.find_config_entry_data(HA_WEBOSTV_DOMAIN) is not None + + def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: + return {self.name: {}} + + def validate_action(self, action: str | None) -> bool: + """The action is found for each TV, not configured""" + return action is None + + def notify_action(self, entity_id: str) -> str | None: + """The TV's legacy notify action, named by the integration after its config entry""" + title: str | None = self.hass_api.config_entry_title_for_entity(entity_id) + if not title: + return None + service: str = slugify(title) + return f"notify.{service}" if self.hass_api.has_service("notify", service) else None + + async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: + _LOGGER.debug("SUPERNOTIFY webostv %s", envelope.message) + + action_data: dict[str, Any] = {ATTR_MESSAGE: envelope.message or ""} + icon: str | None = (envelope.data or {}).get(ATTR_ICON) + if icon: + action_data[ATTR_DATA] = {ATTR_ICON: icon} + + sent: int = 0 + for entity_id in envelope.target.entity_ids: + state = self.hass_api.get_state(entity_id) + if state is not None and state.state in (STATE_OFF, STATE_UNAVAILABLE): + _LOGGER.debug("SUPERNOTIFY webostv skipping %s, which is %s", entity_id, state.state) + continue + action: str | None = self.notify_action(entity_id) + if action is None: + _LOGGER.warning("SUPERNOTIFY webostv no notify action found for %s", entity_id) + continue + if await self.call_action( + envelope, qualified_action=action, action_data=dict(action_data), implied_target=True, single_target=True + ): + sent += 1 + + if not sent and not envelope.error_count: + envelope.skipped = 1 + envelope.skip_reason = SuppressionReason.NO_TARGET + return sent > 0 diff --git a/docs/configuration/deliveries.md b/docs/configuration/deliveries.md index 5de1586aaa..a4f5f08358 100644 --- a/docs/configuration/deliveries.md +++ b/docs/configuration/deliveries.md @@ -19,7 +19,7 @@ A Delivery gets configured automatically for every usable [transport](../transpo For example, if there is an [SMTP Integration](https://www.home-assistant.io/integrations/smtp/) an `email` Delivery will be set up, with the SMTP `action`. -Whether a standard delivery counts as `default` (auto-selected) or `explicit` (only fires when named) depends on whether its transport can definitively claim something in the notification. `email`, `sms`, `mobile_push`, `discord`, `matrix`, `mqtt`, `telegram`, `html5`, `alexa_devices` and the generic notify-entity delivery are all `default`: each has its own dedicated target category (an email address, a phone number, a `discord_channel:`/`matrix_room:`/`topic:`/`telegram_chat_id:`-qualified value, a matching `notify.*` entity), so they only actually fire once a notification's target or an available recipient's own details give them something to claim - a bare `message`, with nothing for any of them to match, reaches none of them. Transports without a dedicated category of their own - `kodi`, `tts`, the generic `media` transport, `alexa_media_player`, `chime`, `persistent`, `generic`, `ntfy`, `gotify`, `lametric`, `pushover` - stay `explicit` always, since there's no way to tell from the target alone that a notification was meant for them (a bare `media_player.x` entity, for instance, doesn't say whether the intent was an image, a sound, speech, or Kodi's own playback). +Whether a standard delivery counts as `default` (auto-selected) or `explicit` (only fires when named) depends on whether its transport can definitively claim something in the notification. `email`, `sms`, `mobile_push`, `discord`, `matrix`, `mqtt`, `telegram`, `html5`, `alexa_devices`, `webostv` and the generic notify-entity delivery are all `default`: each has its own dedicated target category (an email address, a phone number, a `discord_channel:`/`matrix_room:`/`topic:`/`telegram_chat_id:`-qualified value, a matching `notify.*` entity, an LG TV's `media_player` entity), so they only actually fire once a notification's target or an available recipient's own details give them something to claim - a bare `message`, with nothing for any of them to match, reaches none of them. Transports without a dedicated category of their own - `kodi`, `tts`, the generic `media` transport, `alexa_media_player`, `chime`, `persistent`, `generic`, `ntfy`, `gotify`, `lametric`, `pushover` - stay `explicit` always, since there's no way to tell from the target alone that a notification was meant for them (a bare `media_player.x` entity, for instance, doesn't say whether the intent was an image, a sound, speech, or Kodi's own playback). In any case, the `inclusion` key can be used, either in the Transport or Delivery configuration, to override any of the automatic choices. See [Delivery Inclusion](#delivery-inclusion) for more on how that works. @@ -185,6 +185,8 @@ delivery: The primary purpose of the `default` (or "implicit") deliveries is to provide a means of handling known targets. For example, if an email address is in the list of targets, it implies that an `email` delivery is used for it, even if `email` is not on the list. If there are no email addresses the `email` delivery does nothing. Likewise for `discord_channel:xxx` or `topic:my_mqtt_queue` type target addresses. +An `area_id`, `floor_id` or `label_id` target counts by what's in it, so targeting the lounge uses the `webostv` delivery if there's an LG TV in the lounge, and `alexa_devices` if there's an Echo. + ### Delivery Control diff --git a/docs/developer/roadmap.md b/docs/developer/roadmap.md index 89fb449b91..40117921f5 100644 --- a/docs/developer/roadmap.md +++ b/docs/developer/roadmap.md @@ -133,6 +133,10 @@ Rethink `footer`, which is a key in `data` that only plain emails use. For examp - Support `dir`, for right-to-left languages, perhaps set from the Home Assistant language as `lang` now is - Support `timestamp`, for example from when a camera event happened rather than when the notification was sent +### LG webOS TV + +The LG webOS TV integration still uses a legacy notify action for each TV, named after its config entry, so the `webostv` transport maps each TV's `media_player` entity to that action through its config entry, to reach TVs by area. If the integration moves to notify entities, as Home Assistant is doing for notify platforms, the `notify_entity` transport would cover the TVs, area included. Then the transport could be dropped, or kept only for skipping TVs that are off. + ### Alert Review integration with `alert` - align live activities where possible with its behaviour and syntax, add recipe, make sure it works well with `notifiers`. diff --git a/docs/transports/webostv.md b/docs/transports/webostv.md new file mode 100644 index 0000000000..9c6da9de67 --- /dev/null +++ b/docs/transports/webostv.md @@ -0,0 +1,70 @@ +--- +tags: + - transport + - webostv +--- +# LG webOS TV Transport Adaptor + +## Motivation + +Shows notifications as a toast on LG webOS TVs, through the Home Assistant +[LG webOS TV](https://www.home-assistant.io/integrations/webostv/#notifications) integration. + +The integration gives each TV a notify action of its own, such as `notify.living_room_tv`, rather than a notify +entity, so on their own the TVs can't be reached by area. This transport targets each TV's `media_player` +entity instead, and finds its notify action, so a TV can be reached by area, floor, label or device as well as by +its entity. + +## Features + +* No set up: a `webostv` delivery is made whenever the LG webOS TV integration is set up. +* Only sent to a TV when it is targeted, directly or by its area, floor, label or device, so a TV isn't + interrupted by every notification in the house. +* TVs that are off, or unavailable, are skipped, as the integration can't reach them. +* Only the message is shown. The title is used only when there's no message. + +## Configuration + +Nothing is needed. Target the TV, or where it is: + +```yaml +action: notify.supernotify +data: + message: Dinner is ready + target: + area_id: lounge +``` + +Targeting the whole ground floor reaches every TV on it that's on, as well as anything else there, such as Alexa +devices: + +```yaml +action: notify.supernotify +data: + message: Someone's at the door + target: + floor_id: ground +``` + +## webOS TV Data Keys + +* `icon` — path to an image file on the Home Assistant host, shown as the toast's icon. Any other `data` is left + out, since the integration takes nothing else + +```yaml +action: notify.supernotify +data: + message: Dinner is ready + target: + area_id: lounge + data: + icon: /config/www/dinner.png +``` + +## Notes + +* The notify action is named by the integration after the TV, as it was called when set up. After renaming the TV, + its notify action keeps the old name until the integration is reloaded. +* A TV that is skipped because it's off isn't an error, so a delivery `fallback:` isn't used for it. To reach + someone another way when the TV is off, use a [scenario](../configuration/scenarios.md) with a condition on the + TV's state. diff --git a/docs/usage/targets.md b/docs/usage/targets.md index ebc8ba3377..d376f19daa 100644 --- a/docs/usage/targets.md +++ b/docs/usage/targets.md @@ -22,7 +22,7 @@ Categories marked *Auto* are recognized from the format of the target itself. Th | Category | Auto | Used By | | --------------------------------- | ---- | ------- | -| `entity_id` | Y | Alexa Devices, Alexa Media Player, Chime, HTML5, Kodi, Media Player, Notify Entity, TTS | +| `entity_id` | Y | Alexa Devices, Alexa Media Player, Chime, HTML5, Kodi, LG webOS TV, Media Player, Notify Entity, TTS | | `device_id` | Y | Chime | | `email` | Y | Email | | `mobile_app_id` | Y | Mobile Push, TTS | diff --git a/tests/components/supernotify/integration/test_integration_webostv.py b/tests/components/supernotify/integration/test_integration_webostv.py new file mode 100644 index 0000000000..43f1237d0b --- /dev/null +++ b/tests/components/supernotify/integration/test_integration_webostv.py @@ -0,0 +1,114 @@ +"""End to end flows to LG webOS TVs, found with no YAML and targeted by area or floor""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er + +from tests.components.supernotify.ha_testing import MockConfigEntry + +from .framework import House + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +# TV config entry title -> area +TVS: dict[str, str] = {"Lounge TV": "lounge", "Bedroom TV": "bedroom"} + + +async def webos_house(hass: HomeAssistant, bedroom_tv_state: str = "on") -> House: + entries: dict[str, MockConfigEntry] = {} + for title in TVS: + entries[title] = MockConfigEntry(domain="webostv", title=title, data={}) + entries[title].add_to_hass(hass) + house = House( + floors=["ground", "first"], + areas={"lounge": "ground", "kitchen": "ground", "bedroom": "first"}, + alexa_per_area=False, + stub_actions=["notify.lounge_tv", "notify.bedroom_tv"], + ) + await house.setup(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + for title, area in TVS.items(): + object_id = title.lower().replace(" ", "_") + # as the integration sets a TV up, the area is on its device, not the entity + device = device_registry.async_get_or_create( + config_entry_id=entries[title].entry_id, identifiers={("webostv", object_id)}, name=title + ) + device_registry.async_update_device(device.id, area_id=house.areas[area].id) + tv = entity_registry.async_get_or_create( + "media_player", + "webostv", + f"{object_id}_id", + suggested_object_id=object_id, + config_entry=entries[title], + device_id=device.id, + ) + hass.states.async_set(tv.entity_id, bedroom_tv_state if area == "bedroom" else "on") + return house + + +async def test_area_target_reaches_its_tv(hass: HomeAssistant) -> None: + house = await webos_house(hass) + await house.assert_e2e( + """ + message: Dinner is ready + target: + area_id: lounge + """, + expected_calls={"notify": ["lounge_tv"]}, + ) + assert house.service_calls["notify"][0].data == {"message": "Dinner is ready"} + + +async def test_area_without_tv_reaches_none(hass: HomeAssistant) -> None: + house = await webos_house(hass) + await house.assert_e2e( + """ + message: Dinner is ready + target: + area_id: kitchen + """, + expected_calls=None, + ) + + +async def test_floor_target_reaches_tvs_on_it(hass: HomeAssistant) -> None: + house = await webos_house(hass) + await house.assert_e2e( + """ + message: Bedtime + target: + floor_id: + - ground + - first + """, + expected_calls={"notify": ["lounge_tv", "bedroom_tv"]}, + ) + + +async def test_tv_that_is_off_is_skipped(hass: HomeAssistant) -> None: + house = await webos_house(hass, bedroom_tv_state="off") + await house.assert_e2e( + """ + message: Bedtime + target: + floor_id: + - ground + - first + """, + expected_calls={"notify": ["lounge_tv"]}, + ) + + +async def test_untargeted_notification_leaves_tvs_alone(hass: HomeAssistant) -> None: + house = await webos_house(hass) + await house.assert_e2e( + """ + message: Bedtime + """, + expected_calls=None, + ) diff --git a/tests/components/supernotify/integration/test_town_house.py b/tests/components/supernotify/integration/test_town_house.py index 1ae2cc602b..150c333932 100644 --- a/tests/components/supernotify/integration/test_town_house.py +++ b/tests/components/supernotify/integration/test_town_house.py @@ -154,10 +154,10 @@ async def test_notify_targets_floor_and_area_with_default_deliveries_via_own_dev device_id: - {town_house.supernotify_device_id()} """, - # mobile_push (a unique-category delivery, selection_rank FIRST) dispatches before - # notify_entity (a fallback-category delivery, selection_rank LAST) - see - # Notification.select_deliveries()'s ordering - expected_calls={"notify": ["mobile_app_alice_phone", "mobile_app_bob_phone", "send_message"]}, + # the area/floor's Alexa echoes go to alexa_devices, which claims them as its own + # unique category once the selectors are resolved, and dispatches before mobile_push - + # see Notification.select_deliveries()'s ordering + expected_calls={"notify": ["send_message", "mobile_app_alice_phone", "mobile_app_bob_phone"]}, expected_entities={ "notify": [ "notify.kitchen", diff --git a/tests/components/supernotify/integration/test_zero_yaml.py b/tests/components/supernotify/integration/test_zero_yaml.py index 6b6903f303..e8223d2551 100644 --- a/tests/components/supernotify/integration/test_zero_yaml.py +++ b/tests/components/supernotify/integration/test_zero_yaml.py @@ -116,10 +116,10 @@ async def test_notify_targets_floor_and_area_with_default_deliveries_via_own_dev device_id: - {town_house.supernotify_device_id()} """, - # mobile_push (a unique-category delivery, selection_rank FIRST) dispatches before - # notify_entity (a fallback-category delivery, selection_rank LAST) - see - # Notification.select_deliveries()'s ordering - expected_calls={"notify": ["mobile_app_alice_phone", "mobile_app_bob_phone", "send_message"]}, + # the area/floor's Alexa echoes go to alexa_devices, which claims them as its own + # unique category once the selectors are resolved, and dispatches before mobile_push - + # see Notification.select_deliveries()'s ordering + expected_calls={"notify": ["send_message", "mobile_app_alice_phone", "mobile_app_bob_phone"]}, expected_entities={ "notify": [ "notify.kitchen", diff --git a/tests/components/supernotify/transports/test_transport_webostv.py b/tests/components/supernotify/transports/test_transport_webostv.py new file mode 100644 index 0000000000..49d57e9286 --- /dev/null +++ b/tests/components/supernotify/transports/test_transport_webostv.py @@ -0,0 +1,142 @@ +"""Tests for the LG webOS TV transport""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers import entity_registry as er + +from custom_components.supernotify.model import TransportFeature +from custom_components.supernotify.notification import Notification +from custom_components.supernotify.schema import EnvelopeOutcome +from custom_components.supernotify.transports.webostv import WebOsTvTransport +from tests.components.supernotify.ha_testing import MockConfigEntry +from tests.components.supernotify.hass_setup_lib import TestingContext + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant, ServiceCall + + +def add_tv(hass: HomeAssistant, title: str, object_id: str, state: str = "on") -> str: + """A TV as the webostv integration sets one up - a config entry, its media_player and its notify action""" + entry = MockConfigEntry(domain="webostv", title=title, data={}) + entry.add_to_hass(hass) + reg_entry = er.async_get(hass).async_get_or_create( + "media_player", "webostv", f"{object_id}_id", suggested_object_id=object_id, config_entry=entry + ) + hass.states.async_set(reg_entry.entity_id, state) + return reg_entry.entity_id + + +async def setup(hass: HomeAssistant, *tvs: tuple[str, str, str]) -> tuple[TestingContext, list[ServiceCall], list[str]]: + calls: list[ServiceCall] = [] + + async def record(call: ServiceCall) -> None: + calls.append(call) + + entity_ids: list[str] = [] + for title, object_id, state in tvs: + entity_ids.append(add_tv(hass, title, object_id, state)) + hass.services.async_register("notify", object_id, record) + ctx = TestingContext(homeassistant=hass, transport_types=[WebOsTvTransport]) + await ctx.test_initialize() + return ctx, calls, entity_ids + + +async def test_standard_delivery_only_when_integration_set_up(hass: HomeAssistant) -> None: + ctx = TestingContext(homeassistant=hass, transport_types=[WebOsTvTransport]) + await ctx.test_initialize() + assert "webostv" not in ctx.delivery_registry.deliveries + + ctx, _, _ = await setup(hass, ("Living Room TV", "living_room_tv", "on")) + assert "webostv" in ctx.delivery_registry.deliveries + assert "webostv" in [d.name for d in ctx.delivery_registry.implicit_deliveries] + assert ctx.delivery_registry.deliveries["webostv"].transport.supported_features == TransportFeature.MESSAGE + + +async def test_sends_to_each_tv_by_its_own_action(hass: HomeAssistant) -> None: + ctx, calls, (lounge, bedroom) = await setup(hass, ("Lounge TV", "lounge_tv", "on"), ("Bedroom TV", "bedroom_tv", "on")) + + uut = Notification(ctx, "Dinner is ready", target=[lounge, bedroom]) + await uut.initialize() + await uut.deliver() + + assert list(uut.selected_deliveries) == ["webostv"] + assert sorted(c.service for c in calls) == ["bedroom_tv", "lounge_tv"] + assert all(c.data == {"message": "Dinner is ready"} for c in calls) + + +async def test_not_selected_without_a_tv_target(hass: HomeAssistant) -> None: + ctx, calls, _ = await setup(hass, ("Lounge TV", "lounge_tv", "on")) + + uut = Notification(ctx, "Dinner is ready") + await uut.initialize() + await uut.deliver() + + assert "webostv" not in uut.selected_deliveries + assert calls == [] + + +async def test_skips_tvs_that_are_off_or_unavailable(hass: HomeAssistant) -> None: + ctx, calls, entity_ids = await setup( + hass, + ("Lounge TV", "lounge_tv", "on"), + ("Bedroom TV", "bedroom_tv", "off"), + ("Garage TV", "garage_tv", "unavailable"), + ) + + uut = Notification(ctx, "Dinner is ready", target=entity_ids) + await uut.initialize() + await uut.deliver() + + assert [c.service for c in calls] == ["lounge_tv"] + + +async def test_all_tvs_off_is_skipped_not_an_error(hass: HomeAssistant) -> None: + ctx, calls, (bedroom,) = await setup(hass, ("Bedroom TV", "bedroom_tv", "off")) + + uut = Notification(ctx, "Dinner is ready", target=[bedroom]) + await uut.initialize() + await uut.deliver() + + assert calls == [] + assert uut.deliveries["webostv"].get(EnvelopeOutcome.SUPPRESSED) + assert not uut.deliveries["webostv"].get(EnvelopeOutcome.ERROR) + assert uut.error_count == 0 + + +async def test_title_only_used_without_message(hass: HomeAssistant) -> None: + ctx, calls, (lounge,) = await setup(hass, ("Lounge TV", "lounge_tv", "on")) + + uut = Notification(ctx, "Dinner is ready", title="Kitchen", target=[lounge]) + await uut.initialize() + await uut.deliver() + uut = Notification(ctx, None, title="Kitchen", target=[lounge]) + await uut.initialize() + await uut.deliver() + + assert [c.data["message"] for c in calls] == ["Dinner is ready", "Kitchen"] + + +async def test_icon_passed_through_other_data_dropped(hass: HomeAssistant) -> None: + ctx, calls, (lounge,) = await setup(hass, ("Lounge TV", "lounge_tv", "on")) + + uut = Notification( + ctx, "Dinner is ready", target=[lounge], action_data={"data": {"icon": "/config/www/dinner.png", "other": 1}} + ) + await uut.initialize() + await uut.deliver() + + assert calls[0].data == {"message": "Dinner is ready", "data": {"icon": "/config/www/dinner.png"}} + + +async def test_tv_without_notify_action_is_missed(hass: HomeAssistant) -> None: + entity_id = add_tv(hass, "Lounge TV", "lounge_tv") + ctx = TestingContext(homeassistant=hass, transport_types=[WebOsTvTransport]) + await ctx.test_initialize() + + uut = Notification(ctx, "Dinner is ready", target=[entity_id]) + await uut.initialize() + await uut.deliver() + + assert uut.deliveries["webostv"].get(EnvelopeOutcome.SUPPRESSED) From cd0d4c392202d36bdb6937884c8a1ff6d2a3279b Mon Sep 17 00:00:00 2001 From: Jey Burrows Date: Sun, 8 Nov 2026 15:06:01 +0000 Subject: [PATCH 2/2] Keep a transport's own skip reason instead of replacing it with UNKNOWN A transport that sends nothing can set envelope.skip_reason, such as NO_TARGET for Kodi, Discord or a webOS TV that's off, but the engine always recorded UNKNOWN. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ custom_components/supernotify/notification.py | 5 ++++- .../supernotify/transports/test_transport_webostv.py | 6 ++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 339a7083a1..4fe4246d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ This release continues the move of configuration from YAML into the configuratio - *Transports*: [LG webOS TV](https://supernotify.rhizomatics.org.uk/latest/transports/webostv/) transport, set up automatically with the LG webOS TV integration, showing a toast on each TV targeted directly or by area, floor, label or device, and skipping TVs that are off ([#313](https://github.com/rhizomatics/supernotify/issues/313)) - *Deliveries*: an `area_id`, `floor_id` or `label_id` target now selects a delivery by what's in it, so an area with an Alexa echo uses `alexa_devices`, rather than the generic notify entity delivery +## 🐛 Bug fixes +- *Archive*: a delivery its transport skipped now keeps the transport's reason, such as `NO_TARGET` for Kodi or Discord with no valid target, rather than always showing `UNKNOWN` + ## 📝 Other changes ### Development - *Preview Environment*: a Docker preview Home Assistant running the checkout's Supernotify, set up on first start as the same house the integration tests use, with `ha_repl_server` for agent access with `ha-repl`. `mise run clean` removes it. See [Preview Environment](https://supernotify.rhizomatics.org.uk/developer/preview/) diff --git a/custom_components/supernotify/notification.py b/custom_components/supernotify/notification.py index 4deabc20e9..0f5ff131cf 100644 --- a/custom_components/supernotify/notification.py +++ b/custom_components/supernotify/notification.py @@ -1014,7 +1014,10 @@ async def call_transport( self.record_result(delivery, envelope) else: _LOGGER.info("SUPERNOTIFY No delivery for %s (id: %s)", delivery.name, envelope.notification_id) - self.record_result(delivery, envelope, suppression_reason=SuppressionReason.UNKNOWN) + # keep a reason the transport gave, such as NO_TARGET when every target was skipped + self.record_result( + delivery, envelope, suppression_reason=envelope.skip_reason or SuppressionReason.UNKNOWN + ) except Exception as e2: envelope.error_count = envelope.error_count + 1 transport.record_error(str(e2), method="deliver") diff --git a/tests/components/supernotify/transports/test_transport_webostv.py b/tests/components/supernotify/transports/test_transport_webostv.py index 49d57e9286..ed6b19d748 100644 --- a/tests/components/supernotify/transports/test_transport_webostv.py +++ b/tests/components/supernotify/transports/test_transport_webostv.py @@ -6,7 +6,7 @@ from homeassistant.helpers import entity_registry as er -from custom_components.supernotify.model import TransportFeature +from custom_components.supernotify.model import SuppressionReason, TransportFeature from custom_components.supernotify.notification import Notification from custom_components.supernotify.schema import EnvelopeOutcome from custom_components.supernotify.transports.webostv import WebOsTvTransport @@ -100,7 +100,9 @@ async def test_all_tvs_off_is_skipped_not_an_error(hass: HomeAssistant) -> None: await uut.deliver() assert calls == [] - assert uut.deliveries["webostv"].get(EnvelopeOutcome.SUPPRESSED) + envelope = uut.deliveries["webostv"][EnvelopeOutcome.SUPPRESSED][0] # type: ignore + # the transport's own reason is kept, not replaced with UNKNOWN + assert envelope.skip_reason == SuppressionReason.NO_TARGET assert not uut.deliveries["webostv"].get(EnvelopeOutcome.ERROR) assert uut.error_count == 0