Add zwave_js device config file change fix/repair (#99314)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Raman Gupta 2023-08-30 11:29:22 -04:00 committed by GitHub
parent 501d5db375
commit 867e9b73bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 263 additions and 16 deletions

View File

@ -596,6 +596,26 @@ class NodeEvents:
node, 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( async def async_handle_discovery_info(
self, self,
device: dr.DeviceEntry, device: dr.DeviceEntry,

View File

@ -3,7 +3,7 @@
"name": "Z-Wave", "name": "Z-Wave",
"codeowners": ["@home-assistant/z-wave"], "codeowners": ["@home-assistant/z-wave"],
"config_flow": true, "config_flow": true,
"dependencies": ["usb", "http", "websocket_api"], "dependencies": ["usb", "http", "repairs", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/zwave_js", "documentation": "https://www.home-assistant.io/integrations/zwave_js",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",

View File

@ -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()

View File

@ -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": { "services": {

View File

@ -3,7 +3,7 @@ import asyncio
import copy import copy
import io import io
import json import json
from unittest.mock import AsyncMock, patch from unittest.mock import DEFAULT, AsyncMock, patch
import pytest import pytest
from zwave_js_server.event import Event from zwave_js_server.event import Event
@ -687,9 +687,17 @@ def mock_client_fixture(
client.version = VersionInfo.from_message(version_state) client.version = VersionInfo.from_message(version_state)
client.ws_server_url = "ws://test:3000/zjs" 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 = { client.async_send_command.return_value = {
"result": {"success": True, "status": 255} "result": {"success": True, "status": 255}
} }
client.async_send_command.side_effect = async_send_command_side_effect
yield client yield client

View File

@ -124,7 +124,7 @@ async def test_number_writeable(
blocking=True, 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] args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value" assert args["command"] == "node.set_value"
assert args["nodeId"] == 4 assert args["nodeId"] == 4

View File

@ -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

View File

@ -271,12 +271,12 @@ async def test_update_entity_ha_not_running(
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() 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_start()
await hass.async_block_till_done() 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 # Update should be delayed by a day because HA is not running
hass.state = CoreState.starting 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)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5))
await hass.async_block_till_done() 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 hass.state = CoreState.running
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1))
await hass.async_block_till_done() await hass.async_block_till_done()
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_list[0][0][0] args = client.async_send_command.call_args_list[1][0][0]
assert args["command"] == "controller.get_available_firmware_updates" assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == zen_31.node_id 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.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() 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_start()
await hass.async_block_till_done() 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)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 1 assert len(client.async_send_command.call_args_list) == 3
args = client.async_send_command.call_args_list[0][0][0] args = client.async_send_command.call_args_list[2][0][0]
assert args["command"] == "controller.get_available_firmware_updates" assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 2 assert len(client.async_send_command.call_args_list) == 4
args = client.async_send_command.call_args_list[1][0][0] args = client.async_send_command.call_args_list[3][0][0]
assert args["command"] == "controller.get_available_firmware_updates" assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == zen_31.node_id assert args["nodeId"] == zen_31.node_id
@ -741,8 +741,8 @@ async def test_update_entity_full_restore_data_update_available(
attrs = state.attributes attrs = state.attributes
assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_IN_PROGRESS] is True
assert len(client.async_send_command.call_args_list) == 1 assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[0][0][0] == { assert client.async_send_command.call_args_list[1][0][0] == {
"command": "controller.firmware_update_ota", "command": "controller.firmware_update_ota",
"nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id,
"updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}],