diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index adbdb10cf89..f1a4150ad1a 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -10,7 +10,12 @@ from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol from zwave_js_server import dump from zwave_js_server.client import Client -from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.exceptions import ( BaseZwaveJSServerError, FailedCommand, @@ -19,7 +24,7 @@ from zwave_js_server.exceptions import ( SetValueFailed, ) from zwave_js_server.firmware import begin_firmware_update -from zwave_js_server.model.controller import ControllerStatistics +from zwave_js_server.model.controller import ControllerStatistics, InclusionGrant from zwave_js_server.model.firmware import ( FirmwareUpdateFinished, FirmwareUpdateProgress, @@ -67,7 +72,8 @@ TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" VALUE = "value" -SECURE = "secure" +INCLUSION_STRATEGY = "inclusion_strategy" +PIN = "pin" # constants for log config commands CONFIG = "config" @@ -85,6 +91,10 @@ STATUS = "status" ENABLED = "enabled" OPTED_IN = "opted_in" +# constants for granting security classes +SECURITY_CLASSES = "security_classes" +CLIENT_SIDE_AUTH = "client_side_auth" + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -171,6 +181,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_ping_node) websocket_api.async_register_command(hass, websocket_add_node) + websocket_api.async_register_command(hass, websocket_grant_security_classes) + websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) websocket_api.async_register_command(hass, websocket_remove_node) @@ -371,7 +383,9 @@ async def websocket_ping_node( { vol.Required(TYPE): "zwave_js/add_node", vol.Required(ENTRY_ID): str, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -386,11 +400,7 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -404,6 +414,26 @@ async def websocket_add_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -426,6 +456,7 @@ async def websocket_add_node( "node_id": node.node_id, "status": node.status, "ready": node.ready, + "low_security": event["result"].get("lowSecurity", False), } connection.send_message( websocket_api.event_message( @@ -452,6 +483,8 @@ async def websocket_add_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node added", node_added), async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered @@ -465,6 +498,59 @@ async def websocket_add_node( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/grant_security_classes", + vol.Required(ENTRY_ID): str, + vol.Required(SECURITY_CLASSES): [ + vol.In([sec_cls.value for sec_cls in SecurityClass]) + ], + vol.Optional(CLIENT_SIDE_AUTH, default=False): bool, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_grant_security_classes( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + inclusion_grant = InclusionGrant( + [SecurityClass(sec_cls) for sec_cls in msg[SECURITY_CLASSES]], + msg[CLIENT_SIDE_AUTH], + ) + await client.driver.controller.async_grant_security_classes(inclusion_grant) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/validate_dsk_and_enter_pin", + vol.Required(ENTRY_ID): str, + vol.Required(PIN): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_validate_dsk_and_enter_pin( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + await client.driver.controller.async_validate_dsk_and_enter_pin(msg[PIN]) + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -583,7 +669,9 @@ async def websocket_remove_node( vol.Required(TYPE): "zwave_js/replace_failed_node", vol.Required(ENTRY_ID): str, vol.Required(NODE_ID): int, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -599,11 +687,7 @@ async def websocket_replace_failed_node( """Replace a failed node with a new node.""" controller = client.driver.controller node_id = msg[NODE_ID] - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -617,6 +701,26 @@ async def websocket_replace_failed_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -678,6 +782,8 @@ async def websocket_replace_failed_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node removed", node_removed), controller.on("node added", node_added), async_dispatcher_connect( diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 29c0ce4bba4..1551b55a429 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3,7 +3,12 @@ import json from unittest.mock import patch import pytest -from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -16,6 +21,7 @@ from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( + CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, ENABLED, @@ -24,13 +30,15 @@ from homeassistant.components.zwave_js.api import ( FILENAME, FORCE_CONSOLE, ID, + INCLUSION_STRATEGY, LEVEL, LOG_TO_FILE, NODE_ID, OPTED_IN, + PIN, PROPERTY, PROPERTY_KEY, - SECURE, + SECURITY_CLASSES, TYPE, VALUE, ) @@ -354,31 +362,6 @@ async def test_ping_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_add_node_secure( - hass, nortek_thermostat_added_event, integration, client, hass_ws_client -): - """Test the add_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - {ID: 1, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, SECURE: True} - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_add_node( hass, nortek_thermostat_added_event, integration, client, hass_ws_client ): @@ -389,7 +372,12 @@ async def test_add_node( client.async_send_command.return_value = {"success": True} await ws_client.send_json( - {ID: 3, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + { + ID: 3, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, + } ) msg = await ws_client.receive_json() @@ -398,7 +386,7 @@ async def test_add_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } event = Event( @@ -414,6 +402,37 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + client.driver.receive_event(nortek_thermostat_added_event) msg = await ws_client.receive_json() assert msg["event"]["event"] == "node added" @@ -421,6 +440,7 @@ async def test_add_node( "node_id": 67, "status": 0, "ready": False, + "low_security": False, } assert msg["event"]["node"] == node_details @@ -503,6 +523,94 @@ async def test_add_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_grant_security_classes(hass, integration, client, hass_ws_client): + """Test the grant_security_classes websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.grant_security_classes", + "inclusionGrant": {"securityClasses": [0], "clientSideAuth": False}, + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_client): + """Test the validate_dsk_and_enter_pin websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.validate_dsk_and_enter_pin", + "pin": "test", + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_client): """Test cancelling the inclusion and exclusion process.""" entry = integration @@ -607,7 +715,6 @@ async def test_remove_node( data={ "source": "controller", "event": "exclusion started", - "secure": False, }, ) client.driver.receive_event(event) @@ -666,52 +773,6 @@ async def test_remove_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_replace_failed_node_secure( - hass, - nortek_thermostat, - integration, - client, - hass_ws_client, -): - """Test the replace_failed_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - dev_reg = dr.async_get(hass) - - # Create device registry entry for mock node - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, "3245146787-67")}, - name="Node 67", - ) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - { - ID: 1, - TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, - SECURE: True, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - assert msg["result"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.replace_failed_node", - "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_replace_failed_node( hass, nortek_thermostat, @@ -744,6 +805,7 @@ async def test_replace_failed_node( TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, } ) @@ -755,7 +817,7 @@ async def test_replace_failed_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } client.async_send_command.reset_mock() @@ -773,12 +835,42 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + event = Event( type="inclusion stopped", data={ "source": "controller", "event": "inclusion stopped", - "secure": False, }, ) client.driver.receive_event(event) diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json index 0f90d2ae147..98ae03afbf2 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json @@ -251,5 +251,6 @@ } } ] - } -} \ No newline at end of file + }, + "result": {} +}