Add zwave_js device config file change fix/repair (#99314)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/96541/head^2
parent
501d5db375
commit
867e9b73bb
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"}],
|
||||
|
|
Loading…
Reference in New Issue