diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 787014c0cc87..6ea51075e880 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -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, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index f329d8ba11a8..de2c74479bae 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -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, diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 239dfe5662ac..6e3c8b156b2a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -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 diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index b7f451945f4c..a2bb7d7e7286 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -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 diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index a80c77eb5b2b..c4ff33d316ef 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -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() diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 319d70b4e426..43bf531f378b 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -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()