From ad139b259b4df3a60c83aeecb4f6bfc8e98e6d07 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 17:49:25 +0200 Subject: [PATCH] Add notify entity to System Bridge integration (#171736) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/system_bridge/__init__.py | 7 +- .../components/system_bridge/entity.py | 4 +- .../components/system_bridge/notify.py | 47 ++++++- .../components/system_bridge/strings.json | 3 + .../system_bridge/snapshots/test_notify.ambr | 52 ++++++++ tests/components/system_bridge/test_notify.py | 126 ++++++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 tests/components/system_bridge/snapshots/test_notify.ambr create mode 100644 tests/components/system_bridge/test_notify.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c6e64cbd4fb5..0127486a8645 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -215,12 +215,9 @@ async def async_setup_entry( entry.runtime_data = coordinator - # Set up all platforms except notify - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Set up notify platform + # Set up legacy notify platform hass.async_create_task( discovery.async_load_platform( hass, diff --git a/homeassistant/components/system_bridge/entity.py b/homeassistant/components/system_bridge/entity.py index b37e55cf4066..91a25fa10245 100644 --- a/homeassistant/components/system_bridge/entity.py +++ b/homeassistant/components/system_bridge/entity.py @@ -17,13 +17,13 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): self, coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, - key: str, + key: str | None = None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname - self._key = f"{self._hostname}_{key}" + self._key = f"{self._hostname}_{key}" if key is not None else self._hostname self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" ) diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index cc08011cfbe1..de64677a293b 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -3,6 +3,7 @@ import logging from typing import Any +from systembridgeconnector.exceptions import ConnectionClosedException from systembridgeconnector.models.notification import Notification from homeassistant.components.notify import ( @@ -10,12 +11,18 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, ) -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID +from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity _LOGGER = logging.getLogger(__name__) @@ -25,6 +32,44 @@ ATTR_IMAGE = "image" ATTR_TIMEOUT = "timeout" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SystemBridgeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notification entity platform.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + [SystemBridgeNotifyEntity(coordinator, config_entry.data[CONF_PORT])] + ) + + +class SystemBridgeNotifyEntity(SystemBridgeEntity, NotifyEntity): + """Representation of a notification entity.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + _attr_name = None + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message via notify.send_message action.""" + notification = Notification( + message=message, title=ATTR_TITLE_DEFAULT if title is None else title + ) + try: + await self.coordinator.websocket_client.send_notification(notification) + except ConnectionClosedException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders={ + "title": self.coordinator.config_entry.title, + "host": self.coordinator.config_entry.data[CONF_HOST], + }, + ) from e + + async def async_get_service( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 933a89feb87f..f7e86d71791b 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -114,6 +114,9 @@ "process_not_found": { "message": "Could not find process with ID {id}." }, + "send_message_failed": { + "message": "Failed to send message to {title} ({host}) due to a connection error" + }, "timeout": { "message": "A timeout occurred for {title} ({host})" }, diff --git a/tests/components/system_bridge/snapshots/test_notify.ambr b/tests/components/system_bridge/snapshots/test_notify.ambr new file mode 100644 index 000000000000..e0d3d9d77a5a --- /dev/null +++ b/tests/components/system_bridge/snapshots/test_notify.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_notify_platform[notify.hostname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.hostname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'system_bridge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'hostname', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.hostname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'hostname', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.hostname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/system_bridge/test_notify.py b/tests/components/system_bridge/test_notify.py new file mode 100644 index 000000000000..96138b949348 --- /dev/null +++ b/tests/components/system_bridge/test_notify.py @@ -0,0 +1,126 @@ +"""Tests for the System Bridge notify platform.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from systembridgeconnector.exceptions import ConnectionClosedException +from systembridgeconnector.models.notification import Notification + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def notify_only() -> Generator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.system_bridge.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_version", "mock_websocket_client") +async def test_notify_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_version") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_send_message( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_websocket_client: AsyncMock, +) -> None: + """Test sending a message.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.hostname") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.hostname", + ATTR_MESSAGE: "World", + ATTR_TITLE: "Hello", + }, + blocking=True, + ) + + state = hass.states.get("notify.hostname") + assert state + assert state.state == "2009-02-13T23:31:30+00:00" + + mock_websocket_client.send_notification.assert_awaited_once_with( + Notification(title="Hello", message="World") + ) + + +@pytest.mark.usefixtures("mock_version") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_send_message_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_websocket_client: AsyncMock, +) -> None: + """Test sending a message with exception.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_websocket_client.send_notification.side_effect = ConnectionClosedException + + with pytest.raises(HomeAssistantError) as e: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.hostname", + ATTR_MESSAGE: "World", + ATTR_TITLE: "Hello", + }, + blocking=True, + ) + + mock_websocket_client.send_notification.assert_awaited_once_with( + Notification(title="Hello", message="World") + ) + assert e.value.translation_key == "send_message_failed" + assert e.value.translation_placeholders == { + "title": "TestSystem", + "host": "127.0.0.1", + }