mirror of
https://github.com/home-assistant/core.git
synced 2026-09-12 11:38:47 +01:00
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:
co-authored by
J. Nick Koston
J. Nick Koston
Copilot
parent
dee3c11203
commit
f410d94f80
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user