Fix Z-Wave handling of driver ready event (#149879)

This commit is contained in:
Martin Hjelmare 2025-08-03 11:23:01 +02:00 committed by GitHub
parent b2349ac2bd
commit fea5c63bba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 259 additions and 204 deletions

View File

@ -105,7 +105,6 @@ from .const import (
CONF_USB_PATH, CONF_USB_PATH,
CONF_USE_ADDON, CONF_USE_ADDON,
DOMAIN, DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_DEVICE_ADDED_TO_REGISTRY,
EVENT_VALUE_UPDATED, EVENT_VALUE_UPDATED,
LIB_LOGGER, LIB_LOGGER,
@ -136,6 +135,7 @@ from .models import ZwaveJSConfigEntry, ZwaveJSData
from .services import async_setup_services from .services import async_setup_services
CONNECT_TIMEOUT = 10 CONNECT_TIMEOUT = 10
DRIVER_READY_TIMEOUT = 60
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -368,6 +368,16 @@ class DriverEvents:
) )
) )
# listen for driver ready event to reload the config entry
self.config_entry.async_on_unload(
driver.on(
"driver ready",
lambda _: self.hass.config_entries.async_schedule_reload(
self.config_entry.entry_id
),
)
)
# listen for new nodes being added to the mesh # listen for new nodes being added to the mesh
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
controller.on( controller.on(

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from contextlib import suppress from contextlib import suppress
import dataclasses import dataclasses
@ -87,7 +86,6 @@ from .const import (
CONF_DATA_COLLECTION_OPTED_IN, CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE, CONF_INSTALLER_MODE,
DOMAIN, DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER, LOGGER,
USER_AGENT, USER_AGENT,
@ -98,6 +96,7 @@ from .helpers import (
async_get_node_from_device_id, async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id, async_get_provisioning_entry_from_device_id,
async_get_version_info, async_get_version_info,
async_wait_for_driver_ready_event,
get_device_id, get_device_id,
) )
@ -2854,26 +2853,18 @@ async def websocket_hard_reset_controller(
connection.send_result(msg[ID], device.id) connection.send_result(msg[ID], device.id)
async_cleanup() async_cleanup()
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
msg[DATA_UNSUBSCRIBE] = unsubs = [ msg[DATA_UNSUBSCRIBE] = unsubs = [
async_dispatcher_connect( async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added
), ),
driver.once("driver ready", set_driver_ready),
] ]
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
await driver.async_hard_reset() await driver.async_hard_reset()
with suppress(TimeoutError): with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT): await wait_for_driver_ready()
await wait_driver_ready.wait()
# When resetting the controller, the controller home id is also changed. # When resetting the controller, the controller home id is also changed.
# The controller state in the client is stale after resetting the controller, # The controller state in the client is stale after resetting the controller,
# so get the new home id with a new client using the helper function. # so get the new home id with a new client using the helper function.
@ -2886,14 +2877,14 @@ async def websocket_hard_reset_controller(
# The stale unique id needs to be handled by a repair flow, # The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded. # after the config entry has been reloaded.
LOGGER.error( LOGGER.error(
"Failed to get server version, cannot update config entry" "Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset" "unique id with new home id, after controller reset"
) )
else: else:
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id) entry, unique_id=str(version_info.home_id)
) )
await hass.config_entries.async_reload(entry.entry_id) hass.config_entries.async_schedule_reload(entry.entry_id)
@websocket_api.websocket_command( @websocket_api.websocket_command(
@ -3100,27 +3091,19 @@ async def websocket_restore_nvm(
) )
) )
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
# Set up subscription for progress events # Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [ msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm convert progress", forward_progress), controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress), controller.on("nvm restore progress", forward_progress),
driver.once("driver ready", set_driver_ready),
] ]
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False})
with suppress(TimeoutError): with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT): await wait_for_driver_ready()
await wait_driver_ready.wait()
# When restoring the NVM to the controller, the controller home id is also changed. # When restoring the NVM to the controller, the controller home id is also changed.
# The controller state in the client is stale after restoring the NVM, # The controller state in the client is stale after restoring the NVM,
# so get the new home id with a new client using the helper function. # so get the new home id with a new client using the helper function.
@ -3133,14 +3116,13 @@ async def websocket_restore_nvm(
# The stale unique id needs to be handled by a repair flow, # The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded. # after the config entry has been reloaded.
LOGGER.error( LOGGER.error(
"Failed to get server version, cannot update config entry" "Failed to get server version, cannot update config entry "
"unique id with new home id, after controller NVM restore" "unique id with new home id, after controller NVM restore"
) )
else: else:
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id) entry, unique_id=str(version_info.home_id)
) )
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
connection.send_message( connection.send_message(
@ -3152,3 +3134,4 @@ async def websocket_restore_nvm(
) )
) )
connection.send_result(msg[ID]) connection.send_result(msg[ID])
async_cleanup()

View File

@ -62,9 +62,12 @@ from .const import (
CONF_USB_PATH, CONF_USB_PATH,
CONF_USE_ADDON, CONF_USE_ADDON,
DOMAIN, DOMAIN,
DRIVER_READY_TIMEOUT,
) )
from .helpers import CannotConnect, async_get_version_info from .helpers import (
CannotConnect,
async_get_version_info,
async_wait_for_driver_ready_event,
)
from .models import ZwaveJSConfigEntry from .models import ZwaveJSConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -1396,19 +1399,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
event["bytesWritten"] / event["total"] * 0.5 + 0.5 event["bytesWritten"] / event["total"] * 0.5 + 0.5
) )
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
driver = self._get_driver() driver = self._get_driver()
controller = driver.controller controller = driver.controller
wait_driver_ready = asyncio.Event()
unsubs = [ unsubs = [
controller.on("nvm convert progress", forward_progress), controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress), controller.on("nvm restore progress", forward_progress),
driver.once("driver ready", set_driver_ready),
] ]
wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver)
try: try:
await controller.async_restore_nvm( await controller.async_restore_nvm(
self.backup_data, {"preserveRoutes": False} self.backup_data, {"preserveRoutes": False}
@ -1417,8 +1416,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
raise AbortFlow(f"Failed to restore network: {err}") from err raise AbortFlow(f"Failed to restore network: {err}") from err
else: else:
with suppress(TimeoutError): with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT): await wait_for_driver_ready()
await wait_driver_ready.wait()
try: try:
version_info = await async_get_version_info( version_info = await async_get_version_info(
self.hass, config_entry.data[CONF_URL] self.hass, config_entry.data[CONF_URL]
@ -1435,10 +1433,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
config_entry, unique_id=str(version_info.home_id) config_entry, unique_id=str(version_info.home_id)
) )
await self.hass.config_entries.async_reload(config_entry.entry_id)
# Reload the config entry two times to clean up # The config entry will be also be reloaded when the driver is ready,
# the stale device entry. # by the listener in the package module,
# and two reloads are needed to clean up the stale controller device entry.
# Since both the old and the new controller have the same node id, # Since both the old and the new controller have the same node id,
# but different hardware identifiers, the integration # but different hardware identifiers, the integration
# will create a new device for the new controller, on the first reload, # will create a new device for the new controller, on the first reload,

View File

@ -201,7 +201,3 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
} }
# Other constants
DRIVER_READY_TIMEOUT = 60

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable, Coroutine
from dataclasses import astuple, dataclass from dataclasses import astuple, dataclass
import logging import logging
from typing import Any, cast from typing import Any, cast
@ -56,6 +56,7 @@ from .const import (
) )
from .models import ZwaveJSConfigEntry from .models import ZwaveJSConfigEntry
DRIVER_READY_EVENT_TIMEOUT = 60
SERVER_VERSION_TIMEOUT = 10 SERVER_VERSION_TIMEOUT = 10
@ -588,5 +589,57 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio
return version_info return version_info
@callback
def async_wait_for_driver_ready_event(
config_entry: ZwaveJSConfigEntry,
driver: Driver,
) -> Callable[[], Coroutine[Any, Any, None]]:
"""Wait for the driver ready event and the config entry reload.
When the driver ready event is received
the config entry will be reloaded by the integration.
This function helps wait for that to happen
before proceeding with further actions.
If the config entry is reloaded for another reason,
this function will not wait for it to be reloaded again.
Raises TimeoutError if the driver ready event and reload
is not received within the specified timeout.
"""
driver_ready_event_received = asyncio.Event()
config_entry_reloaded = asyncio.Event()
unsubscribers: list[Callable[[], None]] = []
@callback
def driver_ready_received(event: dict) -> None:
"""Receive the driver ready event."""
driver_ready_event_received.set()
unsubscribers.append(driver.once("driver ready", driver_ready_received))
@callback
def on_config_entry_state_change() -> None:
"""Check config entry was loaded after driver ready event."""
if config_entry.state is ConfigEntryState.LOADED:
config_entry_reloaded.set()
unsubscribers.append(
config_entry.async_on_state_change(on_config_entry_state_change)
)
async def wait_for_events() -> None:
try:
async with asyncio.timeout(DRIVER_READY_EVENT_TIMEOUT):
await asyncio.gather(
driver_ready_event_received.wait(), config_entry_reloaded.wait()
)
finally:
for unsubscribe in unsubscribers:
unsubscribe()
return wait_for_events
class CannotConnect(HomeAssistantError): class CannotConnect(HomeAssistantError):
"""Indicate connection error.""" """Indicate connection error."""

View File

@ -1,5 +1,6 @@
"""Test the Z-Wave JS Websocket API.""" """Test the Z-Wave JS Websocket API."""
import asyncio
from copy import deepcopy from copy import deepcopy
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
@ -5109,17 +5110,12 @@ async def test_hard_reset_controller(
ws_client = await hass_ws_client(hass) ws_client = await hass_ws_client(hass)
assert entry.unique_id == "3245146787" assert entry.unique_id == "3245146787"
async def async_send_command_driver_ready( async def mock_driver_hard_reset() -> None:
message: dict[str, Any],
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
client.driver.emit( client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"} "driver ready", {"event": "driver ready", "source": "driver"}
) )
return {}
client.async_send_command.side_effect = async_send_command_driver_ready client.driver.async_hard_reset = AsyncMock(side_effect=mock_driver_hard_reset)
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
{ {
@ -5128,6 +5124,7 @@ async def test_hard_reset_controller(
} }
) )
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device( device = device_registry.async_get_device(
identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])}
@ -5135,16 +5132,10 @@ async def test_hard_reset_controller(
assert device is not None assert device is not None
assert msg["result"] == device.id assert msg["result"] == device.id
assert msg["success"] assert msg["success"]
assert client.driver.async_hard_reset.call_count == 1
assert client.async_send_command.call_count == 3
# The first call is the relevant hard reset command.
# 25 is the require_schema parameter.
assert client.async_send_command.call_args_list[0] == call(
{"command": "driver.hard_reset"}, 25
)
assert entry.unique_id == "1234" assert entry.unique_id == "1234"
client.async_send_command.reset_mock() client.driver.async_hard_reset.reset_mock()
# Test client connect error when getting the server version. # Test client connect error when getting the server version.
@ -5158,6 +5149,7 @@ async def test_hard_reset_controller(
) )
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device( device = device_registry.async_get_device(
identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])}
@ -5165,33 +5157,24 @@ async def test_hard_reset_controller(
assert device is not None assert device is not None
assert msg["result"] == device.id assert msg["result"] == device.id
assert msg["success"] assert msg["success"]
assert client.driver.async_hard_reset.call_count == 1
assert client.async_send_command.call_count == 3
# The first call is the relevant hard reset command.
# 25 is the require_schema parameter.
assert client.async_send_command.call_args_list[0] == call(
{"command": "driver.hard_reset"}, 25
)
assert ( assert (
"Failed to get server version, cannot update config entry" "Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset" "unique id with new home id, after controller reset"
) in caplog.text ) in caplog.text
client.async_send_command.reset_mock() client.driver.async_hard_reset.reset_mock()
get_server_version.side_effect = None
# Test sending command with driver not ready and timeout. # Test sending command with driver not ready and timeout.
async def async_send_command_no_driver_ready( async def mock_driver_hard_reset_no_driver_ready() -> None:
message: dict[str, Any], pass
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
return {}
client.async_send_command.side_effect = async_send_command_no_driver_ready client.driver.async_hard_reset.side_effect = mock_driver_hard_reset_no_driver_ready
with patch( with patch(
"homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT",
new=0, new=0,
): ):
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
@ -5201,6 +5184,7 @@ async def test_hard_reset_controller(
} }
) )
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device( device = device_registry.async_get_device(
identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])}
@ -5208,32 +5192,29 @@ async def test_hard_reset_controller(
assert device is not None assert device is not None
assert msg["result"] == device.id assert msg["result"] == device.id
assert msg["success"] assert msg["success"]
assert client.driver.async_hard_reset.call_count == 1
assert client.async_send_command.call_count == 3 client.driver.async_hard_reset.reset_mock()
# The first call is the relevant hard reset command.
# 25 is the require_schema parameter.
assert client.async_send_command.call_args_list[0] == call(
{"command": "driver.hard_reset"}, 25
)
client.async_send_command.reset_mock()
# Test FailedZWaveCommand is caught # Test FailedZWaveCommand is caught
with patch( client.driver.async_hard_reset.side_effect = FailedZWaveCommand(
"zwave_js_server.model.driver.Driver.async_hard_reset", "failed_command", 1, "error message"
side_effect=FailedZWaveCommand("failed_command", 1, "error message"), )
):
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/hard_reset_controller",
ENTRY_ID: entry.entry_id,
}
)
msg = await ws_client.receive_json()
assert not msg["success"] await ws_client.send_json_auto_id(
assert msg["error"]["code"] == "zwave_error" {
assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" TYPE: "zwave_js/hard_reset_controller",
ENTRY_ID: entry.entry_id,
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "zwave_error"
assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message"
assert client.driver.async_hard_reset.call_count == 1
client.driver.async_hard_reset.side_effect = None
# Test sending command with not loaded entry fails # Test sending command with not loaded entry fails
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
@ -5578,17 +5559,24 @@ async def test_restore_nvm(
# Set up mocks for the controller events # Set up mocks for the controller events
controller = client.driver.controller controller = client.driver.controller
async def async_send_command_driver_ready( async def mock_restore_nvm_base64(
message: dict[str, Any], self, base64_data: str, options: dict[str, bool] | None = None
require_schema: int | None = None, ) -> None:
) -> dict: controller.emit(
"""Send a command and get a response.""" "nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 150, "total": 200},
)
controller.data["homeId"] = 3245146787
client.driver.emit( client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"} "driver ready", {"event": "driver ready", "source": "driver"}
) )
return {}
client.async_send_command.side_effect = async_send_command_driver_ready controller.async_restore_nvm_base64 = AsyncMock(side_effect=mock_restore_nvm_base64)
# Send the subscription request # Send the subscription request
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
@ -5599,7 +5587,19 @@ async def test_restore_nvm(
} }
) )
# Verify the finished event first # Verify the convert progress event
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm convert progress"
assert msg["event"]["bytesRead"] == 100
assert msg["event"]["total"] == 200
# Verify the restore progress event
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 150
assert msg["event"]["total"] == 200
# Verify the finished event
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
assert msg["type"] == "event" assert msg["type"] == "event"
assert msg["event"]["event"] == "finished" assert msg["event"]["event"] == "finished"
@ -5609,53 +5609,18 @@ async def test_restore_nvm(
assert msg["type"] == "result" assert msg["type"] == "result"
assert msg["success"] is True assert msg["success"] is True
# Simulate progress events
event = Event(
"nvm restore progress",
{
"source": "controller",
"event": "nvm restore progress",
"bytesWritten": 25,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 25
assert msg["event"]["total"] == 100
event = Event(
"nvm restore progress",
{
"source": "controller",
"event": "nvm restore progress",
"bytesWritten": 50,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 50
assert msg["event"]["total"] == 100
await hass.async_block_till_done() await hass.async_block_till_done()
# Verify the restore was called # Verify the restore was called
# The first call is the relevant one for nvm restore. # The first call is the relevant one for nvm restore.
assert client.async_send_command.call_count == 3 assert controller.async_restore_nvm_base64.call_count == 1
assert client.async_send_command.call_args_list[0] == call( assert controller.async_restore_nvm_base64.call_args == call(
{ "dGVzdA==",
"command": "controller.restore_nvm", {"preserveRoutes": False},
"nvmData": "dGVzdA==",
"migrateOptions": {"preserveRoutes": False},
},
require_schema=42,
) )
assert entry.unique_id == "1234" assert entry.unique_id == "1234"
client.async_send_command.reset_mock() controller.async_restore_nvm_base64.reset_mock()
# Test client connect error when getting the server version. # Test client connect error when getting the server version.
@ -5670,7 +5635,19 @@ async def test_restore_nvm(
} }
) )
# Verify the finished event first # Verify the convert progress event
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm convert progress"
assert msg["event"]["bytesRead"] == 100
assert msg["event"]["total"] == 200
# Verify the restore progress event
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 150
assert msg["event"]["total"] == 200
# Verify the finished event
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
assert msg["type"] == "event" assert msg["type"] == "event"
assert msg["event"]["event"] == "finished" assert msg["event"]["event"] == "finished"
@ -5680,47 +5657,46 @@ async def test_restore_nvm(
assert msg["type"] == "result" assert msg["type"] == "result"
assert msg["success"] is True assert msg["success"] is True
assert client.async_send_command.call_count == 3 await hass.async_block_till_done()
assert client.async_send_command.call_args_list[0] == call(
{ assert controller.async_restore_nvm_base64.call_count == 1
"command": "controller.restore_nvm", assert controller.async_restore_nvm_base64.call_args == call(
"nvmData": "dGVzdA==", "dGVzdA==",
"migrateOptions": {"preserveRoutes": False}, {"preserveRoutes": False},
},
require_schema=42,
) )
assert ( assert (
"Failed to get server version, cannot update config entry" "Failed to get server version, cannot update config entry "
"unique id with new home id, after controller NVM restore" "unique id with new home id, after controller NVM restore"
) in caplog.text ) in caplog.text
client.async_send_command.reset_mock() controller.async_restore_nvm_base64.reset_mock()
get_server_version.side_effect = None
# Test sending command with driver not ready and timeout. # Test sending command without driver ready event causing timeout.
async def async_send_command_no_driver_ready( async def mock_restore_nvm_without_driver_ready(
message: dict[str, Any], data: bytes, options: dict[str, bool] | None = None
require_schema: int | None = None, ):
) -> dict: controller.data["homeId"] = 3245146787
"""Send a command and get a response."""
return {}
client.async_send_command.side_effect = async_send_command_no_driver_ready controller.async_restore_nvm_base64.side_effect = (
mock_restore_nvm_without_driver_ready
)
with patch( with patch(
"homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT",
new=0, new=0,
): ):
# Send the subscription request # Send the subscription request
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
{ {
"type": "zwave_js/restore_nvm", "type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id, "entry_id": entry.entry_id,
"data": "dGVzdA==", # base64 encoded "test" "data": "dGVzdA==", # base64 encoded "test"
} }
) )
# Verify the finished event first # Verify the finished event
msg = await ws_client.receive_json() msg = await ws_client.receive_json()
assert msg["type"] == "event" assert msg["type"] == "event"
@ -5734,37 +5710,41 @@ async def test_restore_nvm(
await hass.async_block_till_done() await hass.async_block_till_done()
# Verify the restore was called # Verify the restore was called
# The first call is the relevant one for nvm restore. assert controller.async_restore_nvm_base64.call_count == 1
assert client.async_send_command.call_count == 3 assert controller.async_restore_nvm_base64.call_args == call(
assert client.async_send_command.call_args_list[0] == call( "dGVzdA==",
{ {"preserveRoutes": False},
"command": "controller.restore_nvm",
"nvmData": "dGVzdA==",
"migrateOptions": {"preserveRoutes": False},
},
require_schema=42,
) )
client.async_send_command.reset_mock() controller.async_restore_nvm_base64.reset_mock()
# Test restore failure # Test restore failure
with patch( controller.async_restore_nvm_base64.side_effect = FailedZWaveCommand(
f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64", "failed_command", 1, "error message"
side_effect=FailedZWaveCommand("failed_command", 1, "error message"), )
):
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
# Verify error response # Send the subscription request
msg = await ws_client.receive_json() await ws_client.send_json_auto_id(
assert not msg["success"] {
assert msg["error"]["code"] == "zwave_error" "type": "zwave_js/restore_nvm",
"entry_id": entry.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
# Verify error response
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "zwave_error"
await hass.async_block_till_done()
# Verify the restore was called
assert controller.async_restore_nvm_base64.call_count == 1
assert controller.async_restore_nvm_base64.call_args == call(
"dGVzdA==",
{"preserveRoutes": False},
)
# Test entry_id not found # Test entry_id not found
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
@ -5779,13 +5759,13 @@ async def test_restore_nvm(
assert msg["error"]["code"] == "not_found" assert msg["error"]["code"] == "not_found"
# Test config entry not loaded # Test config entry not loaded
await hass.config_entries.async_unload(integration.entry_id) await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await ws_client.send_json_auto_id( await ws_client.send_json_auto_id(
{ {
"type": "zwave_js/restore_nvm", "type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id, "entry_id": entry.entry_id,
"data": "dGVzdA==", # base64 encoded "test" "data": "dGVzdA==", # base64 encoded "test"
} }
) )

View File

@ -1101,7 +1101,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout(
assert restart_addon.call_args == call("core_zwave_js") assert restart_addon.call_args == call("core_zwave_js")
with patch( with patch(
("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"),
new=0, new=0,
): ):
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
@ -1111,7 +1111,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout(
assert client.connect.call_count == 2 assert client.connect.call_count == 2
await hass.async_block_till_done() await hass.async_block_till_done()
assert client.connect.call_count == 4 assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1 assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2 assert len(events) == 2
@ -3897,7 +3897,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout(
assert restart_addon.call_args == call("core_zwave_js") assert restart_addon.call_args == call("core_zwave_js")
with patch( with patch(
("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"),
new=0, new=0,
): ):
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
@ -3907,7 +3907,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout(
assert client.connect.call_count == 2 assert client.connect.call_count == 2
await hass.async_block_till_done() await hass.async_block_till_done()
assert client.connect.call_count == 4 assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1 assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2 assert len(events) == 2

View File

@ -2262,3 +2262,38 @@ async def test_entity_available_when_node_dead(
state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY)
assert state assert state
assert state.state != STATE_UNAVAILABLE assert state.state != STATE_UNAVAILABLE
async def test_driver_ready_event(
hass: HomeAssistant,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test receiving a driver ready event."""
config_entry = integration
assert config_entry.state is ConfigEntryState.LOADED
config_entry_state_changes: list[ConfigEntryState] = []
def on_config_entry_state_change() -> None:
"""Collect config entry state changes."""
config_entry_state_changes.append(config_entry.state)
config_entry.async_on_state_change(on_config_entry_state_change)
driver_ready = Event(
type="driver ready",
data={
"source": "driver",
"event": "driver ready",
},
)
client.driver.receive_event(driver_ready)
await hass.async_block_till_done()
assert len(config_entry_state_changes) == 4
assert config_entry_state_changes[0] == ConfigEntryState.UNLOAD_IN_PROGRESS
assert config_entry_state_changes[1] == ConfigEntryState.NOT_LOADED
assert config_entry_state_changes[2] == ConfigEntryState.SETUP_IN_PROGRESS
assert config_entry_state_changes[3] == ConfigEntryState.LOADED