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_USE_ADDON,
DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY,
EVENT_VALUE_UPDATED,
LIB_LOGGER,
@ -136,6 +135,7 @@ from .models import ZwaveJSConfigEntry, ZwaveJSData
from .services import async_setup_services
CONNECT_TIMEOUT = 10
DRIVER_READY_TIMEOUT = 60
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
self.config_entry.async_on_unload(
controller.on(

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from contextlib import suppress
import dataclasses
@ -87,7 +86,6 @@ from .const import (
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER,
USER_AGENT,
@ -98,6 +96,7 @@ from .helpers import (
async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id,
async_get_version_info,
async_wait_for_driver_ready_event,
get_device_id,
)
@ -2854,26 +2853,18 @@ async def websocket_hard_reset_controller(
connection.send_result(msg[ID], device.id)
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 = [
async_dispatcher_connect(
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()
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
# When resetting the controller, the controller home id is also changed.
# 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.
@ -2893,7 +2884,7 @@ async def websocket_hard_reset_controller(
hass.config_entries.async_update_entry(
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(
@ -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
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm convert 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})
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
# 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,
# so get the new home id with a new client using the helper function.
@ -3140,7 +3123,6 @@ async def websocket_restore_nvm(
hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id)
)
await hass.config_entries.async_reload(entry.entry_id)
connection.send_message(
@ -3152,3 +3134,4 @@ async def websocket_restore_nvm(
)
)
connection.send_result(msg[ID])
async_cleanup()

View File

@ -62,9 +62,12 @@ from .const import (
CONF_USB_PATH,
CONF_USE_ADDON,
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
_LOGGER = logging.getLogger(__name__)
@ -1396,19 +1399,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
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()
controller = driver.controller
wait_driver_ready = asyncio.Event()
unsubs = [
controller.on("nvm convert 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:
await controller.async_restore_nvm(
self.backup_data, {"preserveRoutes": False}
@ -1417,8 +1416,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
raise AbortFlow(f"Failed to restore network: {err}") from err
else:
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
try:
version_info = await async_get_version_info(
self.hass, config_entry.data[CONF_URL]
@ -1435,10 +1433,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.config_entries.async_update_entry(
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 stale device entry.
# The config entry will be also be reloaded when the driver is ready,
# 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,
# but different hardware identifiers, the integration
# 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_NO_POSITION,
}
# Other constants
DRIVER_READY_TIMEOUT = 60

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from dataclasses import astuple, dataclass
import logging
from typing import Any, cast
@ -56,6 +56,7 @@ from .const import (
)
from .models import ZwaveJSConfigEntry
DRIVER_READY_EVENT_TIMEOUT = 60
SERVER_VERSION_TIMEOUT = 10
@ -588,5 +589,57 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio
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):
"""Indicate connection error."""

View File

@ -1,5 +1,6 @@
"""Test the Z-Wave JS Websocket API."""
import asyncio
from copy import deepcopy
from http import HTTPStatus
from io import BytesIO
@ -5109,17 +5110,12 @@ async def test_hard_reset_controller(
ws_client = await hass_ws_client(hass)
assert entry.unique_id == "3245146787"
async def async_send_command_driver_ready(
message: dict[str, Any],
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
async def mock_driver_hard_reset() -> None:
client.driver.emit(
"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(
{
@ -5128,6 +5124,7 @@ async def test_hard_reset_controller(
}
)
msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device(
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 msg["result"] == device.id
assert msg["success"]
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 client.driver.async_hard_reset.call_count == 1
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.
@ -5158,6 +5149,7 @@ async def test_hard_reset_controller(
)
msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device(
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 msg["result"] == device.id
assert msg["success"]
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 client.driver.async_hard_reset.call_count == 1
assert (
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset"
) 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.
async def async_send_command_no_driver_ready(
message: dict[str, Any],
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
return {}
async def mock_driver_hard_reset_no_driver_ready() -> None:
pass
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(
"homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT",
"homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT",
new=0,
):
await ws_client.send_json_auto_id(
@ -5201,6 +5184,7 @@ async def test_hard_reset_controller(
}
)
msg = await ws_client.receive_json()
await hass.async_block_till_done()
device = device_registry.async_get_device(
identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])}
@ -5208,21 +5192,15 @@ async def test_hard_reset_controller(
assert device is not None
assert msg["result"] == device.id
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
)
client.async_send_command.reset_mock()
client.driver.async_hard_reset.reset_mock()
# Test FailedZWaveCommand is caught
with patch(
"zwave_js_server.model.driver.Driver.async_hard_reset",
side_effect=FailedZWaveCommand("failed_command", 1, "error message"),
):
client.driver.async_hard_reset.side_effect = FailedZWaveCommand(
"failed_command", 1, "error message"
)
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/hard_reset_controller",
@ -5234,6 +5212,9 @@ async def test_hard_reset_controller(
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
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
controller = client.driver.controller
async def async_send_command_driver_ready(
message: dict[str, Any],
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
async def mock_restore_nvm_base64(
self, base64_data: str, options: dict[str, bool] | None = None
) -> None:
controller.emit(
"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(
"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
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()
assert msg["type"] == "event"
assert msg["event"]["event"] == "finished"
@ -5609,53 +5609,18 @@ async def test_restore_nvm(
assert msg["type"] == "result"
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()
# Verify the restore was called
# The first call is the relevant one for nvm restore.
assert client.async_send_command.call_count == 3
assert client.async_send_command.call_args_list[0] == call(
{
"command": "controller.restore_nvm",
"nvmData": "dGVzdA==",
"migrateOptions": {"preserveRoutes": False},
},
require_schema=42,
assert controller.async_restore_nvm_base64.call_count == 1
assert controller.async_restore_nvm_base64.call_args == call(
"dGVzdA==",
{"preserveRoutes": False},
)
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.
@ -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()
assert msg["type"] == "event"
assert msg["event"]["event"] == "finished"
@ -5680,47 +5657,46 @@ async def test_restore_nvm(
assert msg["type"] == "result"
assert msg["success"] is True
assert client.async_send_command.call_count == 3
assert client.async_send_command.call_args_list[0] == call(
{
"command": "controller.restore_nvm",
"nvmData": "dGVzdA==",
"migrateOptions": {"preserveRoutes": False},
},
require_schema=42,
await hass.async_block_till_done()
assert controller.async_restore_nvm_base64.call_count == 1
assert controller.async_restore_nvm_base64.call_args == call(
"dGVzdA==",
{"preserveRoutes": False},
)
assert (
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller NVM restore"
) 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(
message: dict[str, Any],
require_schema: int | None = None,
) -> dict:
"""Send a command and get a response."""
return {}
async def mock_restore_nvm_without_driver_ready(
data: bytes, options: dict[str, bool] | None = None
):
controller.data["homeId"] = 3245146787
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(
"homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT",
"homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT",
new=0,
):
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"entry_id": entry.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
# Verify the finished event first
# Verify the finished event
msg = await ws_client.receive_json()
assert msg["type"] == "event"
@ -5734,29 +5710,24 @@ async def test_restore_nvm(
await hass.async_block_till_done()
# Verify the restore was called
# The first call is the relevant one for nvm restore.
assert client.async_send_command.call_count == 3
assert client.async_send_command.call_args_list[0] == call(
{
"command": "controller.restore_nvm",
"nvmData": "dGVzdA==",
"migrateOptions": {"preserveRoutes": False},
},
require_schema=42,
assert controller.async_restore_nvm_base64.call_count == 1
assert controller.async_restore_nvm_base64.call_args == call(
"dGVzdA==",
{"preserveRoutes": False},
)
client.async_send_command.reset_mock()
controller.async_restore_nvm_base64.reset_mock()
# Test restore failure
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64",
side_effect=FailedZWaveCommand("failed_command", 1, "error message"),
):
controller.async_restore_nvm_base64.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,
"entry_id": entry.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
@ -5766,6 +5737,15 @@ async def test_restore_nvm(
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
await ws_client.send_json_auto_id(
{
@ -5779,13 +5759,13 @@ async def test_restore_nvm(
assert msg["error"]["code"] == "not_found"
# 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 ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"entry_id": entry.entry_id,
"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")
with patch(
("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"),
("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"),
new=0,
):
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
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 client.driver.controller.async_restore_nvm.call_count == 1
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")
with patch(
("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"),
("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"),
new=0,
):
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
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 client.driver.controller.async_restore_nvm.call_count == 1
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)
assert state
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