diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index c84d53d6039..da68dc36a6d 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -197,11 +197,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [ platform for platform in SUPPORTED_PLATFORMS - if platform in config and platform not in (Platform.SENSOR, Platform.NOTIFY) + if platform in config and platform is not Platform.SENSOR ], ) - # set up notify platform, no entry support for notify component yet + # set up notify service for backwards compatibility - remove 2024.11 if NotifySchema.PLATFORM in config: hass.async_create_task( discovery.async_load_platform( @@ -232,7 +232,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: platform for platform in SUPPORTED_PLATFORMS if platform in hass.data[DATA_KNX_CONFIG] - and platform not in (Platform.SENSOR, Platform.NOTIFY) + and platform is not Platform.SENSOR ], ], ) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index af0c6b8d01c..77f3db3f9f3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, - "dependencies": ["file_upload", "websocket_api"], + "dependencies": ["file_upload", "repairs", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 74ae86dc5d0..e208e4fd646 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP notification services.""" +"""Support for KNX/IP notifications.""" from __future__ import annotations @@ -7,13 +7,16 @@ from typing import Any from xknx import XKNX from xknx.devices import Notification as XknxNotification -from homeassistant.components.notify import BaseNotificationService -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant import config_entries +from homeassistant.components.notify import BaseNotificationService, NotifyEntity +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS -from .schema import NotifySchema +from .knx_entity import KnxEntity +from .repairs import migrate_notify_issue async def async_get_service( @@ -25,16 +28,11 @@ async def async_get_service( if discovery_info is None: return None - if platform_config := hass.data[DATA_KNX_CONFIG].get(NotifySchema.PLATFORM): + if platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.NOTIFY): xknx: XKNX = hass.data[DOMAIN].xknx notification_devices = [ - XknxNotification( - xknx, - name=device_config[CONF_NAME], - group_address=device_config[KNX_ADDRESS], - value_type=device_config[CONF_TYPE], - ) + _create_notification_instance(xknx, device_config) for device_config in platform_config ] return KNXNotificationService(notification_devices) @@ -59,6 +57,7 @@ class KNXNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a notification to knx bus.""" + migrate_notify_issue(self.hass) if "target" in kwargs: await self._async_send_to_device(message, kwargs["target"]) else: @@ -74,3 +73,41 @@ class KNXNotificationService(BaseNotificationService): for device in self.devices: if device.name in names: await device.set(message) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up notify(s) for KNX platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NOTIFY] + + async_add_entities(KNXNotify(xknx, entity_config) for entity_config in config) + + +def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotification: + """Return a KNX Notification to be used within XKNX.""" + return XknxNotification( + xknx, + name=config[CONF_NAME], + group_address=config[KNX_ADDRESS], + value_type=config[CONF_TYPE], + ) + + +class KNXNotify(NotifyEntity, KnxEntity): + """Representation of a KNX notification entity.""" + + _device: XknxNotification + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX notification.""" + super().__init__(_create_notification_instance(xknx, config)) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + async def async_send_message(self, message: str) -> None: + """Send a notification to knx bus.""" + await self._device.set(message) diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py new file mode 100644 index 00000000000..f0a92850d36 --- /dev/null +++ b/homeassistant/components/knx/repairs.py @@ -0,0 +1,36 @@ +"""Repairs support for KNX.""" + +from __future__ import annotations + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + + +@callback +def migrate_notify_issue(hass: HomeAssistant) -> None: + """Create issue for notify service deprecation.""" + ir.async_create_issue( + hass, + DOMAIN, + "migrate_notify", + breaks_in_ha_version="2024.11.0", + issue_domain=Platform.NOTIFY.value, + is_fixable=True, + is_persistent=True, + translation_key="migrate_notify", + severity=ir.IssueSeverity.WARNING, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert issue_id == "migrate_notify" + return ConfirmRepairFlow() diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 39670b4f92b..462605c3985 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -750,6 +750,7 @@ class NotifySchema(KNXPlatformSchema): vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator, vol.Required(KNX_ADDRESS): ga_validator, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 39b96dddf8f..a69ba106ffd 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -384,5 +384,18 @@ "name": "[%key:common::action::reload%]", "description": "Reloads the KNX integration." } + }, + "issues": { + "migrate_notify": { + "title": "Migration of KNX notify service", + "fix_flow": { + "step": { + "confirm": { + "description": "The KNX `notify` service has been migrated. New `notify` entities are available now.\n\nUpdate any automations to use the new `notify.send_message` exposed by these new entities. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy KNX notify service" + } + } + } + } } } diff --git a/tests/components/knx/test_notify.py b/tests/components/knx/test_notify.py index d843c460c34..94f2d579fc8 100644 --- a/tests/components/knx/test_notify.py +++ b/tests/components/knx/test_notify.py @@ -1,5 +1,6 @@ """Test KNX notify.""" +from homeassistant.components import notify from homeassistant.components.knx.const import KNX_ADDRESS from homeassistant.components.knx.schema import NotifySchema from homeassistant.const import CONF_NAME, CONF_TYPE @@ -8,7 +9,9 @@ from homeassistant.core import HomeAssistant from .conftest import KNXTestKit -async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_legacy_notify_service_simple( + hass: HomeAssistant, knx: KNXTestKit +) -> None: """Test KNX notify can send to one device.""" await knx.setup_integration( { @@ -26,22 +29,7 @@ async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write( "1/0/0", - ( - 0x49, - 0x20, - 0x6C, - 0x6F, - 0x76, - 0x65, - 0x20, - 0x4B, - 0x4E, - 0x58, - 0x0, - 0x0, - 0x0, - 0x0, - ), + (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 0, 0, 0, 0), ) await hass.services.async_call( @@ -56,26 +44,11 @@ async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write( "1/0/0", - ( - 0x49, - 0x20, - 0x6C, - 0x6F, - 0x76, - 0x65, - 0x20, - 0x4B, - 0x4E, - 0x58, - 0x2C, - 0x20, - 0x62, - 0x75, - ), + (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 44, 32, 98, 117), ) -async def test_notify_multiple_sends_to_all_with_different_encodings( +async def test_legacy_notify_service_multiple_sends_to_all_with_different_encodings( hass: HomeAssistant, knx: KNXTestKit ) -> None: """Test KNX notify `type` configuration.""" @@ -110,3 +83,91 @@ async def test_notify_multiple_sends_to_all_with_different_encodings( "1/0/1", (71, 228, 110, 115, 101, 102, 252, 223, 99, 104, 101, 110, 0, 0), ) + + +async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX notify can send to one device.""" + await knx.setup_integration( + { + NotifySchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/0/0", + } + } + ) + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": "notify.test", + notify.ATTR_MESSAGE: "I love KNX", + }, + ) + await knx.assert_write( + "1/0/0", + (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 0, 0, 0, 0), + ) + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": "notify.test", + notify.ATTR_MESSAGE: "I love KNX, but this text is too long for KNX, poor KNX", + }, + ) + await knx.assert_write( + "1/0/0", + (73, 32, 108, 111, 118, 101, 32, 75, 78, 88, 44, 32, 98, 117), + ) + + +async def test_notify_multiple_sends_with_different_encodings( + hass: HomeAssistant, knx: KNXTestKit +) -> None: + """Test KNX notify `type` configuration.""" + await knx.setup_integration( + { + NotifySchema.PLATFORM: [ + { + CONF_NAME: "ASCII", + KNX_ADDRESS: "1/0/0", + CONF_TYPE: "string", + }, + { + CONF_NAME: "Latin-1", + KNX_ADDRESS: "1/0/1", + CONF_TYPE: "latin_1", + }, + ] + } + ) + message = {notify.ATTR_MESSAGE: "Gänsefüßchen"} + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": "notify.ascii", + **message, + }, + ) + await knx.assert_write( + "1/0/0", + # "G?nsef??chen" + (71, 63, 110, 115, 101, 102, 63, 63, 99, 104, 101, 110, 0, 0), + ) + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + { + "entity_id": "notify.latin_1", + **message, + }, + ) + await knx.assert_write( + "1/0/1", + (71, 228, 110, 115, 101, 102, 252, 223, 99, 104, 101, 110, 0, 0), + ) diff --git a/tests/components/knx/test_repairs.py b/tests/components/knx/test_repairs.py new file mode 100644 index 00000000000..4ad06e0addb --- /dev/null +++ b/tests/components/knx/test_repairs.py @@ -0,0 +1,84 @@ +"""Test repairs for KNX integration.""" + +from http import HTTPStatus + +from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS +from homeassistant.components.knx.schema import NotifySchema +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir + +from .conftest import KNXTestKit + +from tests.typing import ClientSessionGenerator + + +async def test_knx_notify_service_issue( + hass: HomeAssistant, + knx: KNXTestKit, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the legacy notify service still works before migration and repair flow is triggered.""" + await knx.setup_integration( + { + NotifySchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/0/0", + } + } + ) + http_client = await hass_client() + + # Assert no issue is present + assert len(issue_registry.issues) == 0 + + # Simulate legacy service being used + assert hass.services.has_service(NOTIFY_DOMAIN, NOTIFY_DOMAIN) + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_DOMAIN, + service_data={"message": "It is too cold!", "target": "test"}, + blocking=True, + ) + await knx.assert_write( + "1/0/0", + (73, 116, 32, 105, 115, 32, 116, 111, 111, 32, 99, 111, 108, 100), + ) + + # Assert the issue is present + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="migrate_notify", + ) + + # Test confirm step in repair flow + resp = await http_client.post( + RepairsFlowIndexView.url, + json={"handler": DOMAIN, "issue_id": "migrate_notify"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + resp = await http_client.post( + RepairsFlowResourceView.url.format(flow_id=flow_id), + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="migrate_notify", + ) + assert len(issue_registry.issues) == 0