ESPHome to subscribe Z-Wave Proxy HOME ID changes (#154696)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Paulus Schoutsen
2025-10-17 20:46:43 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston Copilot
parent dee3c11203
commit f410d94f80
6 changed files with 240 additions and 3 deletions
@@ -542,7 +542,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if Z-Wave capabilities are present and start discovery flow
next_flow_id: str | None = None
if self._device_info.zwave_proxy_feature_flags:
# If the zwave_home_id is not set, we don't know if it's a fresh
# adapter, or the cable is just unplugged. So only start
# the zwave_js config flow automatically if there is a
# zwave_home_id present. If it's a fresh adapter, the manager
# will handle starting the flow once it gets the home id changed
# request from the ESPHome device.
if (
self._device_info.zwave_proxy_feature_flags
and self._device_info.zwave_home_id
):
assert self._connected_address is not None
assert self._port is not None
@@ -559,7 +568,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
},
data=ESPHomeServiceInfo(
name=self._device_info.name,
zwave_home_id=self._device_info.zwave_home_id or None,
zwave_home_id=self._device_info.zwave_home_id,
ip_address=self._connected_address,
port=self._port,
noise_psk=self._noise_psk,
+18 -1
View File
@@ -491,13 +491,30 @@ class RuntimeEntryData:
assert self.client.connected_address
# If the device does not have a zwave_home_id, it means
# either the Z-Wave controller has never been connected
# to the ESPHome device, or the Z-Wave controller has
# never been provisioned with a home ID (brand new).
# Since we cannot tell the difference, and it could
# just be the cable is unplugged we only
# automatically start the flow if we have a home ID.
if not device_info.zwave_home_id:
return
self.async_create_zwave_js_flow(hass, device_info, device_info.zwave_home_id)
def async_create_zwave_js_flow(
self, hass: HomeAssistant, device_info: DeviceInfo, zwave_home_id: int
) -> None:
"""Create a zwave_js config flow for a Z-Wave JS Proxy device."""
assert self.client.connected_address is not None
discovery_flow.async_create_flow(
hass,
"zwave_js",
{"source": config_entries.SOURCE_ESPHOME},
ESPHomeServiceInfo(
name=device_info.name,
zwave_home_id=device_info.zwave_home_id or None,
zwave_home_id=zwave_home_id,
ip_address=self.client.connected_address,
port=self.client.port,
noise_psk=self.client.noise_psk,
@@ -6,6 +6,7 @@ import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@@ -22,6 +23,8 @@ from aioesphomeapi import (
RequiresEncryptionAPIError,
UserService,
UserServiceArgType,
ZWaveProxyRequest,
ZWaveProxyRequestType,
parse_log_message,
)
from awesomeversion import AwesomeVersion
@@ -84,6 +87,8 @@ from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
@@ -557,6 +562,11 @@ class ESPHomeManager:
)
entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
if device_info.zwave_proxy_feature_flags:
entry_data.disconnect_callbacks.add(
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
)
cli.subscribe_home_assistant_states_and_services(
on_state=entry_data.async_update_state,
on_service_call=self.async_on_service_call,
@@ -568,6 +578,25 @@ class ESPHomeManager:
_async_check_firmware_version(hass, device_info, api_version)
_async_check_using_api_password(hass, device_info, bool(self.password))
def _async_zwave_proxy_request(self, request: ZWaveProxyRequest) -> None:
"""Handle a request to create a zwave_js config flow."""
if request.type != ZWaveProxyRequestType.HOME_ID_CHANGE:
return
# ESPHome will send a home id change on every connection
# if the Z-Wave controller is connected to the ESPHome device
# so we know for sure that the Z-Wave controller is connected
# when we get the message. This makes it safe to start
# the zwave_js config flow automatically even if the zwave_home_id
# is 0 (not yet provisioned) as we know for sure the controller
# is connected to the ESPHome device and do not have to guess
# if it's a broken connection or Z-Wave controller or a not
# yet provisioned controller.
zwave_home_id: int = UNPACK_UINT32_BE(request.data[0:4])[0]
assert self.entry_data.device_info is not None
self.entry_data.async_create_zwave_js_flow(
self.hass, self.entry_data.device_info, zwave_home_id
)
async def on_disconnect(self, expected_disconnect: bool) -> None:
"""Run disconnect callbacks on API disconnect."""
entry_data = self.entry_data
@@ -2704,6 +2704,59 @@ async def test_user_flow_starts_zwave_discovery(
assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, zwave_flow_id)
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
async def test_user_flow_no_zwave_discovery_without_home_id(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test that the user flow does not start Z-Wave JS discovery when zwave_home_id is not set."""
# Mock device with Z-Wave capabilities but no home ID
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False,
name="test-zwave-device-no-id",
mac_address="11:22:33:44:55:CC",
zwave_proxy_feature_flags=1,
zwave_home_id=0, # No home ID set (fresh adapter or unplugged)
)
)
mock_client.connected_address = "192.168.1.103"
# Track flow.async_init calls
original_async_init = hass.config_entries.flow.async_init
flow_init_calls = []
async def track_async_init(*args, **kwargs):
flow_init_calls.append((args, kwargs))
return await original_async_init(*args, **kwargs)
with patch.object(
hass.config_entries.flow, "async_init", side_effect=track_async_init
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "192.168.1.103", CONF_PORT: 6053},
)
# Verify the ESPHome entry was created
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-zwave-device-no-id"
assert result["data"] == {
CONF_HOST: "192.168.1.103",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test-zwave-device-no-id",
}
# Verify only ESPHome flow was initiated, no Z-Wave flow
assert len(flow_init_calls) == 1
assert flow_init_calls[0][0][0] == DOMAIN
# Verify next_flow was NOT set
assert "next_flow" not in result
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
async def test_user_flow_no_zwave_discovery_without_capabilities(
hass: HomeAssistant, mock_client: APIClient
@@ -120,3 +120,35 @@ async def test_discover_zwave() -> None:
version=1,
),
)
async def test_discover_zwave_without_home_id() -> None:
"""Test ESPHome does not start Z-Wave discovery without home ID."""
hass = Mock()
entry_data = RuntimeEntryData(
"mock-id",
"mock-title",
Mock(
connected_address="mock-client-address",
port=1234,
noise_psk=None,
),
None,
)
device_info = Mock(
mac_address="mock-device-info-mac",
zwave_proxy_feature_flags=1,
zwave_home_id=0, # No home ID (fresh adapter or unplugged)
)
device_info.name = "mock-device-infoname"
with patch(
"homeassistant.helpers.discovery_flow.async_create_flow"
) as mock_create_flow:
entry_data.async_on_connect(
hass,
device_info,
None,
)
# Verify async_create_flow was NOT called when zwave_home_id is 0
mock_create_flow.assert_not_called()
+97
View File
@@ -21,6 +21,8 @@ from aioesphomeapi import (
UserService,
UserServiceArg,
UserServiceArgType,
ZWaveProxyRequest,
ZWaveProxyRequestType,
)
import pytest
@@ -2378,3 +2380,98 @@ async def test_manager_handle_dynamic_encryption_key_connection_error(
# Verify key was NOT stored due to connection error
assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"]
async def test_zwave_proxy_request_home_id_change(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test Z-Wave proxy request handler with HOME_ID_CHANGE request."""
device_info = {
"name": "test-zwave-proxy",
"mac_address": "11:22:33:44:55:AA",
"zwave_proxy_feature_flags": 1,
}
await mock_esphome_device(
mock_client=mock_client,
device_info=device_info,
)
await hass.async_block_till_done()
# Get the manager's _async_zwave_proxy_request callback
# It's registered via subscribe_zwave_proxy_request
zwave_proxy_callback = None
for call_item in mock_client.subscribe_zwave_proxy_request.call_args_list:
if call_item[0]:
zwave_proxy_callback = call_item[0][0]
break
assert zwave_proxy_callback is not None
# Create a mock request with a different type (not HOME_ID_CHANGE)
# Assuming there are other types, we'll use a placeholder value
request = ZWaveProxyRequest(
type=0, # Not HOME_ID_CHANGE
data=b"\x00\x00\x00\x01",
)
# Track flow creation
with patch(
"homeassistant.helpers.discovery_flow.async_create_flow"
) as mock_create_flow:
# Call the callback
zwave_proxy_callback(request)
await hass.async_block_till_done()
# Verify no flow was created for non-HOME_ID_CHANGE requests
mock_create_flow.assert_not_called()
# Create a mock request with HOME_ID_CHANGE type and zwave_home_id as bytes
zwave_home_id = 1234567890
request = ZWaveProxyRequest(
type=ZWaveProxyRequestType.HOME_ID_CHANGE,
data=zwave_home_id.to_bytes(4, byteorder="big")
+ b"\x00\x00", # Extra bytes should be ignored
)
# Track flow creation
with patch(
"homeassistant.helpers.discovery_flow.async_create_flow"
) as mock_create_flow:
# Call the callback
zwave_proxy_callback(request)
await hass.async_block_till_done()
# Verify async_create_zwave_js_flow was called with correct arguments
mock_create_flow.assert_called_once()
call_args = mock_create_flow.call_args
assert call_args[0][0] == hass
assert call_args[0][1] == "zwave_js"
async def test_no_zwave_proxy_subscribe_without_feature_flags(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test Z-Wave proxy request subscription is not registered without feature flags."""
device_info = {
"name": "test-device",
"mac_address": "11:22:33:44:55:AA",
"zwave_proxy_feature_flags": 0, # No Z-Wave proxy features
}
# Mock the subscribe_zwave_proxy_request method
mock_client.subscribe_zwave_proxy_request = Mock(return_value=lambda: None)
await mock_esphome_device(
mock_client=mock_client,
device_info=device_info,
)
await hass.async_block_till_done()
# Verify subscribe_zwave_proxy_request was NOT called
mock_client.subscribe_zwave_proxy_request.assert_not_called()