From 867e9b73bbcad4f681f8996833d65690b4765527 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 30 Aug 2023 11:29:22 -0400 Subject: [PATCH] Add zwave_js device config file change fix/repair (#99314) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 20 +++ .../components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/repairs.py | 50 ++++++ .../components/zwave_js/strings.json | 11 ++ tests/components/zwave_js/conftest.py | 10 +- tests/components/zwave_js/test_number.py | 2 +- tests/components/zwave_js/test_repairs.py | 158 ++++++++++++++++++ tests/components/zwave_js/test_update.py | 26 +-- 8 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/zwave_js/repairs.py create mode 100644 tests/components/zwave_js/test_repairs.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 316459bdb23..2d158f47e44 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -596,6 +596,26 @@ class NodeEvents: node, ) + # After ensuring the node is set up in HA, we should check if the node's + # device config has changed, and if so, issue a repair registry entry for a + # possible reinterview + if not node.is_controller_node and await node.async_has_device_config_changed(): + async_create_issue( + self.hass, + DOMAIN, + f"device_config_file_changed.{device.id}", + data={"device_id": device.id}, + is_fixable=True, + is_persistent=False, + translation_key="device_config_file_changed", + translation_placeholders={ + "device_name": device.name_by_user + or device.name + or "Unnamed device" + }, + severity=IssueSeverity.WARNING, + ) + async def async_handle_discovery_info( self, device: dr.DeviceEntry, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7371a7a8896..73fa41a8cca 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "codeowners": ["@home-assistant/z-wave"], "config_flow": true, - "dependencies": ["usb", "http", "websocket_api"], + "dependencies": ["usb", "http", "repairs", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py new file mode 100644 index 00000000000..58781941b09 --- /dev/null +++ b/homeassistant/components/zwave_js/repairs.py @@ -0,0 +1,50 @@ +"""Repairs for Z-Wave JS.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol +from zwave_js_server.model.node import Node + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant + +from .helpers import async_get_node_from_device_id + + +class DeviceConfigFileChangedFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, node: Node) -> None: + """Initialize.""" + self.node = node + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + self.hass.async_create_task(self.node.async_refresh_info()) + return self.async_create_entry(title="", data={}) + + return self.async_show_form(step_id="confirm", data_schema=vol.Schema({})) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.split(".")[0] == "device_config_file_changed": + return DeviceConfigFileChangedFlow( + async_get_node_from_device_id(hass, cast(dict, data)["device_id"]) + ) + return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 934307947d8..6435c6b7a54 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -161,6 +161,17 @@ } } } + }, + "device_config_file_changed": { + "title": "Z-Wave device configuration file changed: {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Z-Wave device configuration file changed: {device_name}", + "description": "Z-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." + } + } + } } }, "services": { diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 8bb55e3949b..dcd847a6e12 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -3,7 +3,7 @@ import asyncio import copy import io import json -from unittest.mock import AsyncMock, patch +from unittest.mock import DEFAULT, AsyncMock, patch import pytest from zwave_js_server.event import Event @@ -687,9 +687,17 @@ def mock_client_fixture( client.version = VersionInfo.from_message(version_state) client.ws_server_url = "ws://test:3000/zjs" + + async def async_send_command_side_effect(message, require_schema=None): + """Return the command response.""" + if message["command"] == "node.has_device_config_changed": + return {"changed": False} + return DEFAULT + client.async_send_command.return_value = { "result": {"success": True, "status": 255} } + client.async_send_command.side_effect = async_send_command_side_effect yield client diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 7229d10ebad..7a3ffbda589 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -124,7 +124,7 @@ async def test_number_writeable( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 2 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 4 diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py new file mode 100644 index 00000000000..b1702900d7c --- /dev/null +++ b/tests/components/zwave_js/test_repairs.py @@ -0,0 +1,158 @@ +"""Test the Z-Wave JS repairs module.""" +from copy import deepcopy +from http import HTTPStatus +from unittest.mock import patch + +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components.repairs.issue_handler import ( + async_process_repairs_platforms, +) +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_device_config_file_changed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue.""" + dev_reg = dr.async_get(hass) + # Create a node + node_state = deepcopy(multisensor_6_state) + node = Node(client, node_state) + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + with patch( + "zwave_js_server.model.node.Node.async_has_device_config_changed", + return_value=True, + ): + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + client.async_send_command_no_wait.reset_mock() + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + assert issue["translation_placeholders"] == {"device_name": device.name} + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + assert client.async_send_command_no_wait.call_args[0][0] == { + "command": "node.refresh_info", + "nodeId": node.node_id, + } + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +async def test_invalid_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + integration, +) -> None: + """Test the invalid issue.""" + ir.async_create_issue( + hass, + DOMAIN, + "invalid_issue_id", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="invalid_issue", + ) + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == "invalid_issue_id" + + url = RepairsFlowIndexView.url + resp = await http_client.post( + url, json={"handler": DOMAIN, "issue_id": "invalid_issue_id"} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 5234460bb51..9314b9155f5 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -271,12 +271,12 @@ async def test_update_entity_ha_not_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 # Update should be delayed by a day because HA is not running hass.state = CoreState.starting @@ -284,15 +284,15 @@ async def test_update_entity_ha_not_running( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 hass.state = CoreState.running async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[1][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -591,26 +591,26 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 2 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command.call_args_list) == 3 + args = client.async_send_command.call_args_list[2][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[1][0][0] + assert len(client.async_send_command.call_args_list) == 4 + args = client.async_send_command.call_args_list[3][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -741,8 +741,8 @@ async def test_update_entity_full_restore_data_update_available( attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args_list[0][0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}],