mirror of
https://github.com/home-assistant/core.git
synced 2026-09-07 05:52:27 +01:00
Add Z-Wave JS WebSocket command to get network neighbors (#179363)
This commit is contained in:
@@ -235,9 +235,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> b
|
||||
|
||||
entry.async_on_unload(client.disconnect)
|
||||
|
||||
# Local because runtime_data is not set yet if HA shuts down during setup
|
||||
network_neighbors_lock = asyncio.Lock()
|
||||
|
||||
async def handle_ha_shutdown(event: Event) -> None:
|
||||
"""Handle HA shutdown."""
|
||||
await client.disconnect()
|
||||
# Wait for a running network neighbors refresh, so the client is not
|
||||
# disconnected before it has turned the radio back on
|
||||
async with network_neighbors_lock:
|
||||
await client.disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown)
|
||||
@@ -270,6 +276,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> b
|
||||
entry_runtime_data = ZwaveJSData(
|
||||
client=client,
|
||||
driver_events=driver_events,
|
||||
network_neighbors_lock=network_neighbors_lock,
|
||||
)
|
||||
entry.runtime_data = entry_runtime_data
|
||||
|
||||
@@ -1167,6 +1174,11 @@ async def client_listen(
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Wait for a running network neighbors refresh, so the client is not
|
||||
# disconnected before it has turned the radio back on
|
||||
async with entry.runtime_data.network_neighbors_lock:
|
||||
pass
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
entry_runtime_data = entry.runtime_data
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Websocket API for Z-Wave JS."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import suppress
|
||||
import dataclasses
|
||||
from functools import partial, wraps
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, Literal, cast
|
||||
|
||||
from aiohttp import web, web_exceptions, web_request
|
||||
@@ -104,12 +106,15 @@ if TYPE_CHECKING:
|
||||
from .models import ZwaveJSConfigEntry
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_UNSUBSCRIBE = "unsubs"
|
||||
|
||||
# general API constants
|
||||
ID = "id"
|
||||
ENTRY_ID = "entry_id"
|
||||
ERR_NOT_LOADED = "not_loaded"
|
||||
ERR_RF_TOGGLE_FAILED = "rf_toggle_failed"
|
||||
NODE_ID = "node_id"
|
||||
DEVICE_ID = "device_id"
|
||||
COMMAND_CLASS_ID = "command_class_id"
|
||||
@@ -412,6 +417,7 @@ def async_register_api(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_network_status)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_node_status)
|
||||
websocket_api.async_register_command(hass, websocket_node_status)
|
||||
websocket_api.async_register_command(hass, websocket_network_neighbors)
|
||||
websocket_api.async_register_command(hass, websocket_node_metadata)
|
||||
websocket_api.async_register_command(hass, websocket_node_alerts)
|
||||
websocket_api.async_register_command(hass, websocket_add_node)
|
||||
@@ -618,6 +624,83 @@ async def websocket_node_status(
|
||||
connection.send_result(msg[ID], node_status(node))
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "zwave_js/network_neighbors",
|
||||
vol.Required(ENTRY_ID): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@async_handle_failed_command
|
||||
@async_get_entry
|
||||
async def websocket_network_neighbors(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
entry: ZwaveJSConfigEntry,
|
||||
client: Client,
|
||||
driver: Driver,
|
||||
) -> None:
|
||||
"""Get the node IDs of the neighbors of all nodes in the network.
|
||||
|
||||
Reading the routing table can wedge older controllers when the radio is
|
||||
on or reads overlap, so refreshes are serialized and done with RF off:
|
||||
https://zwave-js.github.io/zwave-js/#/api/controller?id=getnodeneighbors
|
||||
"""
|
||||
controller = driver.controller
|
||||
|
||||
async def restore_rf() -> bool:
|
||||
"""Turn the radio back on, returning False instead of raising."""
|
||||
try:
|
||||
return await controller.async_toggle_rf(True)
|
||||
except BaseZwaveJSServerError:
|
||||
return False
|
||||
|
||||
async def read_network_neighbors() -> tuple[bool, bool, dict[int, list[int]]]:
|
||||
"""Read the neighbors of all nodes while the radio is off."""
|
||||
neighbors: dict[int, list[int]] = {}
|
||||
rf_disabled = False
|
||||
async with entry.runtime_data.network_neighbors_lock:
|
||||
try:
|
||||
rf_disabled = await controller.async_toggle_rf(False)
|
||||
if rf_disabled:
|
||||
# Snapshot the nodes, inclusion/exclusion can mutate the
|
||||
# collection while it is being iterated
|
||||
for node in list(controller.nodes.values()):
|
||||
# Long range nodes are not part of the mesh
|
||||
if node.protocol is Protocols.ZWAVE_LONG_RANGE:
|
||||
continue
|
||||
try:
|
||||
neighbors[
|
||||
node.node_id
|
||||
] = await controller.async_get_node_neighbors(node)
|
||||
except FailedCommand:
|
||||
continue
|
||||
finally:
|
||||
rf_restored = await restore_rf()
|
||||
if not rf_restored:
|
||||
_LOGGER.error(
|
||||
"Failed to re-enable RF after reading the neighbors of"
|
||||
" the nodes of config entry %s",
|
||||
entry.entry_id,
|
||||
)
|
||||
return rf_disabled, rf_restored, neighbors
|
||||
|
||||
# The refresh runs as its own task and is only abandoned on cancellation,
|
||||
# so a closing connection can't interrupt it while the radio is off
|
||||
rf_disabled, rf_restored, neighbors = await asyncio.shield(
|
||||
hass.async_create_task(read_network_neighbors())
|
||||
)
|
||||
if not rf_disabled:
|
||||
connection.send_error(msg[ID], ERR_RF_TOGGLE_FAILED, "Failed to disable RF")
|
||||
return
|
||||
if not rf_restored:
|
||||
connection.send_error(msg[ID], ERR_RF_TOGGLE_FAILED, "Failed to re-enable RF")
|
||||
return
|
||||
connection.send_result(msg[ID], neighbors)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "zwave_js/node_metadata",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Provide models for the Z-Wave integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
@@ -32,6 +33,8 @@ class ZwaveJSData:
|
||||
client: ZwaveClient
|
||||
driver_events: DriverEvents
|
||||
old_server_log_level: LogLevel | None = None
|
||||
# Serializes routing table reads, which require the radio to be off
|
||||
network_neighbors_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
type ZwaveJSConfigEntry = ConfigEntry[ZwaveJSData]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test the Z-Wave JS Websocket API."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from copy import deepcopy
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
@@ -405,6 +406,478 @@ async def test_node_status(
|
||||
assert msg["error"]["code"] == ERR_NOT_LOADED
|
||||
|
||||
|
||||
def mock_neighbors_commands(
|
||||
client: MagicMock,
|
||||
handler: Callable[[dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Record the commands sent to the driver and answer them with handler."""
|
||||
commands: list[dict[str, Any]] = []
|
||||
|
||||
async def _send_command(message: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
|
||||
commands.append(message)
|
||||
return await handler(message)
|
||||
|
||||
client.async_send_command.side_effect = _send_command
|
||||
return commands
|
||||
|
||||
|
||||
async def neighbors_ok(message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Answer both toggling RF and reading neighbors successfully."""
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
return {"neighbors": []}
|
||||
return {"success": True}
|
||||
|
||||
|
||||
async def test_network_neighbors(
|
||||
hass: HomeAssistant,
|
||||
multisensor_6: Node,
|
||||
wallmote_central_scene: Node,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the network_neighbors websocket command."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
# Long range nodes are not part of the mesh and must be skipped
|
||||
wallmote_central_scene.data["protocol"] = Protocols.ZWAVE_LONG_RANGE
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
neighbors = [35, 32] if message["nodeId"] == multisensor_6.node_id else []
|
||||
return {"neighbors": neighbors}
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"1": [],
|
||||
str(multisensor_6.node_id): [35, 32],
|
||||
}
|
||||
# The nodes are read one at a time while the radio is off
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": 1},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": multisensor_6.node_id},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_node_failure(
|
||||
hass: HomeAssistant,
|
||||
multisensor_6: Node,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test a node that fails to report its neighbors is skipped."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] != "controller.get_node_neighbors":
|
||||
return {"success": True}
|
||||
if message["nodeId"] == 1:
|
||||
raise FailedZWaveCommand("failed_command", 1, "error message")
|
||||
return {"neighbors": []}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {str(multisensor_6.node_id): []}
|
||||
assert commands[-1] == {"command": "controller.toggle_rf", "enabled": True}
|
||||
|
||||
|
||||
async def test_network_neighbors_node_added_while_reading(
|
||||
hass: HomeAssistant,
|
||||
multisensor_6: Node,
|
||||
wallmote_central_scene: Node,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test a node joining the network while reading doesn't abort the reads."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
client.driver.controller.nodes.setdefault(999, wallmote_central_scene)
|
||||
return {"neighbors": []}
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"1": [],
|
||||
str(multisensor_6.node_id): [],
|
||||
str(wallmote_central_scene.node_id): [],
|
||||
}
|
||||
assert commands[-1] == {"command": "controller.toggle_rf", "enabled": True}
|
||||
|
||||
|
||||
async def test_network_neighbors_rf_disable_rejected(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the nodes are not read when the radio can't be turned off."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.toggle_rf":
|
||||
return {"success": message["enabled"]}
|
||||
return {"neighbors": []}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "rf_toggle_failed"
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_rf_restore_rejected(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test a radio that can't be turned back on is reported and logged."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.toggle_rf":
|
||||
return {"success": not message["enabled"]}
|
||||
return {"neighbors": []}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "rf_toggle_failed"
|
||||
assert commands[-1] == {"command": "controller.toggle_rf", "enabled": True}
|
||||
assert "Failed to re-enable RF" in caplog.text
|
||||
|
||||
|
||||
async def test_network_neighbors_rf_toggle_error(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test a single response is sent when the radio can't be toggled at all."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["enabled"]:
|
||||
raise FailedZWaveCommand("failed_command", 1, "error message")
|
||||
return {"success": False}
|
||||
|
||||
mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "rf_toggle_failed"
|
||||
assert "Failed to re-enable RF" in caplog.text
|
||||
|
||||
# The next frame is the pong, proving no second response was sent
|
||||
await ws_client.send_json_auto_id({TYPE: "ping"})
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["type"] == "pong"
|
||||
|
||||
|
||||
async def test_network_neighbors_cancelled(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the radio is turned back on when the command is cancelled."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.toggle_rf" and not message["enabled"]:
|
||||
raise asyncio.CancelledError
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
# A cancelled command sends no response, so sync on a ping instead
|
||||
await ws_client.send_json_auto_id({TYPE: "ping"})
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["type"] == "pong"
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_handler_cancelled(
|
||||
hass: HomeAssistant,
|
||||
multisensor_6: Node,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test an abandoned request doesn't interrupt the refresh.
|
||||
|
||||
The refresh must keep the lock and turn the radio back on even when the
|
||||
websocket command handler is cancelled, e.g. by a closing connection.
|
||||
"""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
read_started = asyncio.Event()
|
||||
resume_read = asyncio.Event()
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
read_started.set()
|
||||
await resume_read.wait()
|
||||
return {"neighbors": []}
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
lock = integration.runtime_data.network_neighbors_lock
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
await read_started.wait()
|
||||
|
||||
handler_task = next(
|
||||
task
|
||||
for task in asyncio.all_tasks()
|
||||
if "_handle_async_response" in repr(task.get_coro())
|
||||
)
|
||||
handler_task.cancel()
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
# The abandoned refresh keeps reading with the lock held
|
||||
assert lock.locked()
|
||||
|
||||
resume_read.set()
|
||||
await hass.async_block_till_done()
|
||||
assert not lock.locked()
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": 1},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": multisensor_6.node_id},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_concurrent(
|
||||
hass: HomeAssistant,
|
||||
multisensor_6: Node,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test concurrent requests are serialized so the radio is never shared."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
ws_client_2 = await hass_ws_client(hass)
|
||||
read_started = asyncio.Event()
|
||||
resume_read = asyncio.Event()
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
read_started.set()
|
||||
await resume_read.wait()
|
||||
return {"neighbors": []}
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
await read_started.wait()
|
||||
|
||||
await ws_client_2.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
await ws_client_2.send_json_auto_id({TYPE: "ping"})
|
||||
msg = await ws_client_2.receive_json()
|
||||
assert msg["type"] == "pong"
|
||||
# The second request must not have touched the radio yet
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": 1},
|
||||
]
|
||||
|
||||
resume_read.set()
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
msg = await ws_client_2.receive_json()
|
||||
assert msg["success"]
|
||||
# The second request turns the radio off only after the first turned it on
|
||||
assert commands == [
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": 1},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": multisensor_6.node_id},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
{"command": "controller.toggle_rf", "enabled": False},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": 1},
|
||||
{"command": "controller.get_node_neighbors", "nodeId": multisensor_6.node_id},
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_unload_waits(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test unloading the entry waits for the radio to be turned back on."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
read_started = asyncio.Event()
|
||||
resume_read = asyncio.Event()
|
||||
|
||||
async def handler(message: dict[str, Any]) -> dict[str, Any]:
|
||||
if message["command"] == "controller.get_node_neighbors":
|
||||
read_started.set()
|
||||
await resume_read.wait()
|
||||
return {"neighbors": []}
|
||||
return {"success": True}
|
||||
|
||||
commands = mock_neighbors_commands(client, handler)
|
||||
|
||||
async def mock_disconnect() -> None:
|
||||
commands.append({"command": "disconnect"})
|
||||
|
||||
client.disconnect.side_effect = mock_disconnect
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
await read_started.wait()
|
||||
|
||||
unload_task = hass.async_create_task(
|
||||
hass.config_entries.async_unload(integration.entry_id)
|
||||
)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
# The refresh is holding the lock, so the client must still be connected
|
||||
assert not unload_task.done()
|
||||
|
||||
resume_read.set()
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert await unload_task
|
||||
# The radio was turned back on before the client disconnected
|
||||
assert commands[-2:] == [
|
||||
{"command": "controller.toggle_rf", "enabled": True},
|
||||
{"command": "disconnect"},
|
||||
]
|
||||
|
||||
|
||||
async def test_network_neighbors_invalid_entry(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the network_neighbors websocket command with an invalid entry."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
mock_neighbors_commands(client, neighbors_ok)
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: "fake_entry_id",
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == ERR_NOT_FOUND
|
||||
|
||||
await hass.config_entries.async_unload(integration.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
TYPE: "zwave_js/network_neighbors",
|
||||
ENTRY_ID: integration.entry_id,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == ERR_NOT_LOADED
|
||||
|
||||
|
||||
async def test_node_metadata(
|
||||
hass: HomeAssistant,
|
||||
wallmote_central_scene,
|
||||
|
||||
@@ -26,7 +26,12 @@ from homeassistant.components.persistent_notification import async_dismiss
|
||||
from homeassistant.components.zwave_js import DOMAIN
|
||||
from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext
|
||||
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -158,6 +163,33 @@ async def test_home_assistant_stop(
|
||||
assert client.disconnect.call_count == 1
|
||||
|
||||
|
||||
async def test_home_assistant_stop_waits_for_neighbors_refresh(
|
||||
hass: HomeAssistant,
|
||||
integration: MockConfigEntry,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test stop disconnects only after a neighbors refresh releases the lock.
|
||||
|
||||
The refresh turns the radio back on before releasing, so disconnecting
|
||||
earlier could leave the radio off.
|
||||
"""
|
||||
disconnected = asyncio.Event()
|
||||
|
||||
async def mock_disconnect() -> None:
|
||||
disconnected.set()
|
||||
|
||||
client.disconnect.side_effect = mock_disconnect
|
||||
|
||||
async with integration.runtime_data.network_neighbors_lock:
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert not disconnected.is_set()
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert disconnected.is_set()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "connect_timeout")
|
||||
async def test_initialized_timeout(hass: HomeAssistant) -> None:
|
||||
"""Test we handle a timeout during client initialization."""
|
||||
|
||||
Reference in New Issue
Block a user