From 78524d8f834f906ea1a36f51d0af7bf17bce2b97 Mon Sep 17 00:00:00 2001 From: marvin-w Date: Thu, 28 Apr 2022 20:33:46 +0200 Subject: [PATCH 1/5] Adds websocket commands for KNX integration --- homeassistant/components/knx/__init__.py | 4 + homeassistant/components/knx/const.py | 6 ++ homeassistant/components/knx/websocket.py | 106 ++++++++++++++++++++++ tests/components/knx/test_websocket.py | 97 ++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 homeassistant/components/knx/websocket.py create mode 100644 tests/components/knx/test_websocket.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 1910227a5a4..5c3051978f3 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -84,6 +84,7 @@ from .schema import ( ga_validator, sensor_type_validator, ) +from .websocket import register_websocket_api _LOGGER = logging.getLogger(__name__) @@ -214,6 +215,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + + register_websocket_api(hass) + return True diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index df8f0de3216..15abfc3c68f 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -1,9 +1,12 @@ """Constants for the KNX integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable from enum import Enum from typing import Final, TypedDict +from xknx.telegram import Telegram + from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_COMFORT, @@ -70,6 +73,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] +MessageCallbackType = Callable[[Telegram], None] + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py new file mode 100644 index 00000000000..e5a76838db5 --- /dev/null +++ b/homeassistant/components/knx/websocket.py @@ -0,0 +1,106 @@ +"""KNX Websocket API.""" +from __future__ import annotations + +from collections.abc import Callable + +import voluptuous as vol +from xknx.telegram import Telegram, TelegramDirection +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, AsyncMessageCallbackType, MessageCallbackType + + +def register_websocket_api(hass: HomeAssistant) -> None: + """Register the KNX Websocket API.""" + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_subscribe_telegram) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/info", + } +) +@websocket_api.async_response +async def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + xknx = hass.data[DOMAIN].xknx + connection.send_result( + msg["id"], + { + "version": xknx.version, + "connected": xknx.connection_manager.connected.is_set(), + "current_address": str(xknx.current_address), + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/subscribe_telegrams", + } +) +@websocket_api.async_response +async def ws_subscribe_telegram( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + + async def forward_telegrams(telegram: Telegram) -> None: + """Forward events to websocket.""" + if not isinstance( + telegram.payload, (GroupValueRead, GroupValueWrite, GroupValueResponse) + ): + return + + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "destination_address": str(telegram.destination_address), + "payload": str(telegram.payload.value) + if isinstance( + telegram.payload, (GroupValueWrite, GroupValueResponse) + ) + else "", + "type": str(telegram.payload.__class__.__name__), + "source_address": str(telegram.source_address), + "direction": "label.incoming" + if telegram.direction == TelegramDirection.INCOMING + else "label.outgoing", + "timestamp": str(telegram.timestamp), + }, + ) + ) + + connection.subscriptions[msg["id"]] = await async_subscribe_telegrams( + hass, forward_telegrams + ) + + connection.send_message(websocket_api.result_message(msg["id"])) + + +async def async_subscribe_telegrams( + hass: HomeAssistant, callback: AsyncMessageCallbackType | MessageCallbackType +) -> Callable[[], None]: + """Subscribe to telegram received callback.""" + xknx = hass.data[DOMAIN].xknx + + unregister = xknx.telegram_queue.register_telegram_received_cb( + callback, match_for_outgoing=True + ) + + def async_remove() -> None: + """Remove callback.""" + xknx.telegram_queue.unregister_telegram_received_cb(unregister) + + return async_remove diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py new file mode 100644 index 00000000000..d45560bd333 --- /dev/null +++ b/tests/components/knx/test_websocket.py @@ -0,0 +1,97 @@ +"""KNX Websocket Tests.""" +from homeassistant.components.knx import KNX_ADDRESS, SwitchSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + + +async def test_knx_info_command(hass: HomeAssistant, knx: KNXTestKit, hass_ws_client): + """Test knx/info command.""" + await knx.setup_integration({}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": "knx/info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["version"] is not None + assert res["result"]["connected"] + assert res["result"]["current_address"] == "0.0.0" + + +async def test_knx_subscribe_telegrams_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client +): + """Test knx/subscribe_telegrams command.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/4", + } + } + ) + assert len(hass.states.async_all()) == 1 + + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + + res = await client.receive_json() + assert res["success"], res + + # send incoming events + await knx.receive_read("1/2/3") + await knx.receive_write("1/3/4", True) + await knx.receive_write("1/3/4", False) + await knx.receive_write("1/3/8", (0x34, 0x45)) + # send outgoing events + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/4", True) + + # receive events + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/3" + assert res["event"]["payload"] == "" + assert res["event"]["type"] == "GroupValueRead" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "label.incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == '' + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "label.incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == '' + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "label.incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/8" + assert res["event"]["payload"] == '' + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "label.incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/4" + assert res["event"]["payload"] == '' + assert res["event"]["type"] == "GroupValueWrite" + assert ( + res["event"]["source_address"] == "0.0.0" + ) # needs to be the currently connected IA connected to + assert res["event"]["direction"] == "label.outgoing" + assert res["event"]["timestamp"] is not None From 09d1c90e670fa5fdb4a9df33e52bc17a2cbd06c6 Mon Sep 17 00:00:00 2001 From: marvin-w Date: Thu, 28 Apr 2022 20:54:18 +0200 Subject: [PATCH 2/5] Test invalid payload type for websocket subscription --- tests/components/knx/conftest.py | 21 ++++++++++++++++++++- tests/components/knx/test_websocket.py | 3 ++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index e5cf18b0c3c..f5c322341ea 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -11,7 +11,13 @@ from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, IndividualAddress -from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram.apci import ( + APCI, + GroupValueRead, + GroupValueResponse, + GroupValueWrite, + IndividualAddressRead, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -188,6 +194,19 @@ class KNXTestKit: await self.xknx.telegrams.join() await self.hass.async_block_till_done() + async def receive_individual_address_read(self): + """Inject incoming IndividualAddressRead telegram.""" + self.xknx.telegrams.put_nowait( + Telegram( + destination_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), + direction=TelegramDirection.INCOMING, + payload=IndividualAddressRead(), + source_address=IndividualAddress("1.3.5"), + ) + ) + await self.xknx.telegrams.join() + await self.hass.async_block_till_done() + async def receive_read( self, group_address: str, diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index d45560bd333..eb8bd6d26f3 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -46,6 +46,7 @@ async def test_knx_subscribe_telegrams_command( await knx.receive_read("1/2/3") await knx.receive_write("1/3/4", True) await knx.receive_write("1/3/4", False) + await knx.receive_individual_address_read() await knx.receive_write("1/3/8", (0x34, 0x45)) # send outgoing events await hass.services.async_call( @@ -92,6 +93,6 @@ async def test_knx_subscribe_telegrams_command( assert res["event"]["type"] == "GroupValueWrite" assert ( res["event"]["source_address"] == "0.0.0" - ) # needs to be the currently connected IA connected to + ) # needs to be the IA currently connected to assert res["event"]["direction"] == "label.outgoing" assert res["event"]["timestamp"] is not None From dca7e9cb76f7eea65598aab05b79daf7c06ef347 Mon Sep 17 00:00:00 2001 From: marvin-w Date: Fri, 29 Apr 2022 14:36:14 +0200 Subject: [PATCH 3/5] Add custom panel to KNX integration --- homeassistant/components/knx/__init__.py | 19 +++++++++++++++++++ homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 5c3051978f3..8031c41aa40 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -20,6 +20,7 @@ from xknx.telegram.address import ( parse_device_group_address, ) from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx_custom_panel import get_knx_ui from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -218,6 +219,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: register_websocket_api(hass) + url = "/api/panel_custom/knx_ui" + hass.http.register_static_path(url, get_knx_ui()) + hass.components.frontend.async_register_built_in_panel( + component_name="custom", + sidebar_title="KNX UI", + sidebar_icon="mdi:earth", + frontend_url_path="knx_ui", + config={ + "_panel_custom": { + "name": "knx-custom-panel", + "embed_iframe": False, + "trust_external": False, + "js_url": url, + } + }, + require_admin=True, + ) + return True diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 266eceaacee..9ef9cc80903 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -3,7 +3,7 @@ "name": "KNX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.22.1"], + "requirements": ["xknx==0.22.1", "xknx-custom-panel==1.6.6"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "platinum", "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 35a231a8cd4..9c939eddd63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2484,6 +2484,7 @@ xiaomi-ble==0.9.0 # homeassistant.components.knx xknx==0.22.1 +xknx-custom-panel==1.6.6 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 011c93291e8..e73a78b21b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1682,6 +1682,7 @@ xiaomi-ble==0.9.0 # homeassistant.components.knx xknx==0.22.1 +xknx-custom-panel==1.6.6 # homeassistant.components.bluesound # homeassistant.components.fritz From 1cc4b193ce053c3ba029e3d94affd08b150028f0 Mon Sep 17 00:00:00 2001 From: marvin-w Date: Sat, 30 Apr 2022 18:30:47 +0200 Subject: [PATCH 4/5] Apply review suggestions --- homeassistant/components/knx/__init__.py | 25 +------ homeassistant/components/knx/const.py | 11 +++ homeassistant/components/knx/websocket.py | 85 +++++++++++++++-------- 3 files changed, 71 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8031c41aa40..f9c826bb140 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -20,7 +20,6 @@ from xknx.telegram.address import ( parse_device_group_address, ) from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite -from xknx_custom_panel import get_knx_ui from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -85,7 +84,7 @@ from .schema import ( ga_validator, sensor_type_validator, ) -from .websocket import register_websocket_api +from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -217,26 +216,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf - register_websocket_api(hass) - - url = "/api/panel_custom/knx_ui" - hass.http.register_static_path(url, get_knx_ui()) - hass.components.frontend.async_register_built_in_panel( - component_name="custom", - sidebar_title="KNX UI", - sidebar_icon="mdi:earth", - frontend_url_path="knx_ui", - config={ - "_panel_custom": { - "name": "knx-custom-panel", - "embed_iframe": False, - "trust_external": False, - "js_url": url, - } - }, - require_admin=True, - ) - return True @@ -317,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) + await register_panel(hass) + return True diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 15abfc3c68f..91a579e06b9 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -98,6 +98,17 @@ class KNXConfigEntryData(TypedDict, total=False): knxkeys_password: str +class KNXBusMonitorMessage(TypedDict): + """KNX bus monitor message.""" + + destination_address: str + payload: str + type: str + source_address: str + direction: str + timestamp: str + + class ColorTempModes(Enum): """Color temperature modes for config validation.""" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index e5a76838db5..3cb87698305 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -2,30 +2,52 @@ from __future__ import annotations from collections.abc import Callable +from typing import Final import voluptuous as vol from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx_custom_panel import get_knx_ui -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant +from homeassistant.components import panel_custom, websocket_api +from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, AsyncMessageCallbackType, MessageCallbackType +from .const import ( + DOMAIN, + AsyncMessageCallbackType, + KNXBusMonitorMessage, + MessageCallbackType, +) + +URL_BASE: Final = "/static_knx" -def register_websocket_api(hass: HomeAssistant) -> None: - """Register the KNX Websocket API.""" +async def register_panel(hass: HomeAssistant) -> None: + """Register the KNX Panel and Websocket API.""" websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) + if DOMAIN not in hass.data.get("frontend_panels", {}): + hass.http.register_static_path(URL_BASE, get_knx_ui()) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="knx-panel", + sidebar_title=DOMAIN.upper(), + sidebar_icon="mdi:earth", + module_url=f"{URL_BASE}", + embed_iframe=True, + require_admin=True, + ) + @websocket_api.websocket_command( { vol.Required("type"): "knx/info", } ) -@websocket_api.async_response -async def ws_info( +@callback +def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, @@ -47,13 +69,13 @@ async def ws_info( vol.Required("type"): "knx/subscribe_telegrams", } ) -@websocket_api.async_response -async def ws_subscribe_telegram( +@callback +def ws_subscribe_telegram( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: - """Handle get info command.""" + """Subscribe to incoming and outgoing KNX telegrams.""" async def forward_telegrams(telegram: Telegram) -> None: """Forward events to websocket.""" @@ -62,41 +84,48 @@ async def ws_subscribe_telegram( ): return + payload = ( + str(telegram.payload.value) + if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) + else "" + ) + direction = ( + "label.incoming" + if telegram.direction == TelegramDirection.INCOMING + else "label.outgoing" + ) + bus_message: KNXBusMonitorMessage = KNXBusMonitorMessage( + destination_address=str(telegram.destination_address), + payload=payload, + type=str(telegram.payload.__class__.__name__), + source_address=str(telegram.source_address), + direction=direction, + timestamp=str(telegram.timestamp), + ) + connection.send_message( websocket_api.event_message( msg["id"], - { - "destination_address": str(telegram.destination_address), - "payload": str(telegram.payload.value) - if isinstance( - telegram.payload, (GroupValueWrite, GroupValueResponse) - ) - else "", - "type": str(telegram.payload.__class__.__name__), - "source_address": str(telegram.source_address), - "direction": "label.incoming" - if telegram.direction == TelegramDirection.INCOMING - else "label.outgoing", - "timestamp": str(telegram.timestamp), - }, + bus_message, ) ) - connection.subscriptions[msg["id"]] = await async_subscribe_telegrams( + connection.subscriptions[msg["id"]] = async_subscribe_telegrams( hass, forward_telegrams ) connection.send_message(websocket_api.result_message(msg["id"])) -async def async_subscribe_telegrams( - hass: HomeAssistant, callback: AsyncMessageCallbackType | MessageCallbackType +def async_subscribe_telegrams( + hass: HomeAssistant, + telegram_callback: AsyncMessageCallbackType | MessageCallbackType, ) -> Callable[[], None]: """Subscribe to telegram received callback.""" xknx = hass.data[DOMAIN].xknx unregister = xknx.telegram_queue.register_telegram_received_cb( - callback, match_for_outgoing=True + telegram_callback, match_for_outgoing=True ) def async_remove() -> None: From 3d40828aebcd153e85ed3676ec777c796e78a116 Mon Sep 17 00:00:00 2001 From: marvin-w Date: Sat, 30 Apr 2022 18:44:21 +0200 Subject: [PATCH 5/5] Correct dependencies in manifest --- homeassistant/components/knx/manifest.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 9ef9cc80903..f0db426c249 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -4,6 +4,8 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", "requirements": ["xknx==0.22.1", "xknx-custom-panel==1.6.6"], + "dependencies": ["websocket_api"], + "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "platinum", "iot_class": "local_push",