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

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/96541/head^2
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,
)
# 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,

View File

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

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

View File

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

View File

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

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.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"}],