diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91fa4589074..32c23fe760b 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -51,6 +51,7 @@ TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" VALUE = "value" +SECURE = "secure" # constants for log config commands CONFIG = "config" @@ -129,6 +130,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_add_node) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_remove_node) + websocket_api.async_register_command(hass, websocket_remove_failed_node) + websocket_api.async_register_command(hass, websocket_replace_failed_node) websocket_api.async_register_command(hass, websocket_stop_exclusion) websocket_api.async_register_command(hass, websocket_refresh_node_info) websocket_api.async_register_command(hass, websocket_refresh_node_values) @@ -211,7 +214,7 @@ async def websocket_node_status( { vol.Required(TYPE): "zwave_js/add_node", vol.Required(ENTRY_ID): str, - vol.Optional("secure", default=False): bool, + vol.Optional(SECURE, default=False): bool, } ) @websocket_api.async_response @@ -225,7 +228,7 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - include_non_secure = not msg["secure"] + include_non_secure = not msg[SECURE] @callback def async_cleanup() -> None: @@ -409,6 +412,165 @@ async def websocket_remove_node( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/replace_failed_node", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + vol.Optional(SECURE, default=False): bool, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_replace_failed_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Replace a failed node with a new node.""" + controller = client.driver.controller + include_non_secure = not msg[SECURE] + node_id = msg[NODE_ID] + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_event(event: dict) -> None: + connection.send_message( + websocket_api.event_message(msg[ID], {"event": event["event"]}) + ) + + @callback + def forward_stage(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "stage": event["stageName"]} + ) + ) + + @callback + def node_added(event: dict) -> None: + node = event["node"] + interview_unsubs = [ + node.on("interview started", forward_event), + node.on("interview completed", forward_event), + node.on("interview stage completed", forward_stage), + node.on("interview failed", forward_event), + ] + unsubs.extend(interview_unsubs) + node_details = { + "node_id": node.node_id, + "status": node.status, + "ready": node.ready, + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "node added", "node": node_details} + ) + ) + + @callback + def node_removed(event: dict) -> None: + node = event["node"] + node_details = { + "node_id": node.node_id, + } + + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "node removed", "node": node_details} + ) + ) + + @callback + def device_registered(device: DeviceEntry) -> None: + device_details = { + "name": device.name, + "id": device.id, + "manufacturer": device.manufacturer, + "model": device.model, + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "device registered", "device": device_details} + ) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + unsubs = [ + controller.on("inclusion started", forward_event), + controller.on("inclusion failed", forward_event), + controller.on("inclusion stopped", forward_event), + controller.on("node removed", node_removed), + controller.on("node added", node_added), + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered + ), + ] + + result = await controller.async_replace_failed_node(node_id, include_non_secure) + connection.send_result( + msg[ID], + result, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/remove_failed_node", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_remove_failed_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Remove a failed node from the Z-Wave network.""" + controller = client.driver.controller + node_id = msg[NODE_ID] + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + unsub() + + @callback + def node_removed(event: dict) -> None: + node = event["node"] + node_details = { + "node_id": node.node_id, + } + + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "node removed", "node": node_details} + ) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + unsub = controller.on("node removed", node_removed) + + result = await controller.async_remove_failed_node(node_id) + connection.send_result( + msg[ID], + result, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f192c69b80a..141998526ee 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -146,7 +146,7 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "node added" node_details = { - "node_id": 53, + "node_id": 67, "status": 0, "ready": False, } @@ -160,7 +160,7 @@ async def test_add_node( # Test receiving interview events event = Event( type="interview started", - data={"source": "node", "event": "interview started", "nodeId": 53}, + data={"source": "node", "event": "interview started", "nodeId": 67}, ) client.driver.receive_event(event) @@ -173,7 +173,7 @@ async def test_add_node( "source": "node", "event": "interview stage completed", "stageName": "NodeInfo", - "nodeId": 53, + "nodeId": 67, }, ) client.driver.receive_event(event) @@ -184,7 +184,7 @@ async def test_add_node( event = Event( type="interview completed", - data={"source": "node", "event": "interview completed", "nodeId": 53}, + data={"source": "node", "event": "interview completed", "nodeId": 67}, ) client.driver.receive_event(event) @@ -193,7 +193,7 @@ async def test_add_node( event = Event( type="interview failed", - data={"source": "node", "event": "interview failed", "nodeId": 53}, + data={"source": "node", "event": "interview failed", "nodeId": 67}, ) client.driver.receive_event(event) @@ -289,9 +289,6 @@ async def test_remove_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "exclusion started" - # Add mock node to controller - client.driver.controller.nodes[67] = nortek_thermostat - dev_reg = dr.async_get(hass) # Create device registry entry for mock node @@ -325,6 +322,221 @@ async def test_remove_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_replace_failed_node( + hass, + integration, + client, + hass_ws_client, + nortek_thermostat, + nortek_thermostat_added_event, + nortek_thermostat_removed_event, +): + """Test the replace_failed_node websocket command.""" + 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} + + # Order of events we receive for a successful replacement is `inclusion started`, + # `inclusion stopped`, `node removed`, `node added`, then interview stages. + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + event = Event( + type="inclusion started", + data={ + "source": "controller", + "event": "inclusion started", + "secure": False, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "inclusion started" + + event = Event( + type="inclusion stopped", + data={ + "source": "controller", + "event": "inclusion stopped", + "secure": False, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "inclusion stopped" + + # Fire node removed event + client.driver.receive_event(nortek_thermostat_removed_event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "node removed" + + # Verify device was removed from device registry + device = dev_reg.async_get_device( + identifiers={(DOMAIN, "3245146787-67")}, + ) + assert device is None + + client.driver.receive_event(nortek_thermostat_added_event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "node added" + node_details = { + "node_id": 67, + "status": 0, + "ready": False, + } + assert msg["event"]["node"] == node_details + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "device registered" + # Check the keys of the device item + assert list(msg["event"]["device"]) == ["name", "id", "manufacturer", "model"] + + # Test receiving interview events + event = Event( + type="interview started", + data={"source": "node", "event": "interview started", "nodeId": 67}, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview started" + + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": 67, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview stage completed" + assert msg["event"]["stage"] == "NodeInfo" + + event = Event( + type="interview completed", + data={"source": "node", "event": "interview completed", "nodeId": 67}, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview completed" + + event = Event( + type="interview failed", + data={"source": "node", "event": "interview failed", "nodeId": 67}, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview failed" + + # 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/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_remove_failed_node( + hass, + integration, + client, + hass_ws_client, + nortek_thermostat, + nortek_thermostat_removed_event, +): + """Test the remove_failed_node websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"success": True} + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/remove_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + dev_reg = dr.async_get(hass) + + # Create device registry entry for mock node + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "3245146787-67")}, + name="Node 67", + ) + + # Fire node removed event + client.driver.receive_event(nortek_thermostat_removed_event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "node removed" + + # Verify device was removed from device registry + device = dev_reg.async_get_device( + identifiers={(DOMAIN, "3245146787-67")}, + ) + assert device is None + + # 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/remove_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + async def test_refresh_node_info( hass, client, integration, hass_ws_client, multisensor_6 ): diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json index 60078100caf..0f90d2ae147 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json @@ -2,7 +2,7 @@ "source": "controller", "event": "node added", "node": { - "nodeId": 53, + "nodeId": 67, "index": 0, "status": 0, "ready": false, @@ -17,7 +17,7 @@ "interviewAttempts": 1, "endpoints": [ { - "nodeId": 53, + "nodeId": 67, "index": 0 } ],