From 02142f352d1872be5a7929418bb19fdd2c4f10b5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Oct 2025 05:49:41 -0400 Subject: [PATCH 1/5] Fix awair integration AttributeError when update listener accesses runtime_data (#153521) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: balloob <1444314+balloob@users.noreply.github.com> --- homeassistant/components/awair/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 528c658eff1176..e3e5f1f97fcb8f 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -26,9 +26,6 @@ async def async_setup_entry( if CONF_HOST in config_entry.data: coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session) - config_entry.async_on_unload( - config_entry.add_update_listener(_async_update_listener) - ) else: coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session) @@ -36,6 +33,11 @@ async def async_setup_entry( config_entry.runtime_data = coordinator + if CONF_HOST in config_entry.data: + config_entry.async_on_unload( + config_entry.add_update_listener(_async_update_listener) + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True From 89cf784022e14557fb189af8912d9eae2dd24bad Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 3 Oct 2025 03:56:02 -0600 Subject: [PATCH 2/5] Fix VeSync zero fan speed handling (#153493) Co-authored-by: Joostlek --- homeassistant/components/vesync/fan.py | 3 +- tests/components/vesync/common.py | 11 --- tests/components/vesync/test_platform.py | 97 ------------------------ 3 files changed, 2 insertions(+), 109 deletions(-) delete mode 100644 tests/components/vesync/test_platform.py diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 5eeb524bc24568..23edf1660a0cb7 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -100,8 +100,9 @@ def percentage(self) -> int | None: """Return the currently set speed.""" current_level = self.device.state.fan_level - if self.device.state.mode == VS_FAN_MODE_MANUAL and current_level is not None: + if current_level == 0: + return 0 return ordered_list_item_to_percentage( self.device.fan_levels, current_level ) diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index dd1ef36c783d3b..dd80cf277a2898 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -147,17 +147,6 @@ def mock_multiple_device_responses( ) -def mock_air_purifier_400s_update_response(aioclient_mock: AiohttpClientMocker) -> None: - """Build a response for the Helpers.call_api method for air_purifier_400s with updated data.""" - - device_name = "Air Purifier 400s" - for fixture in DEVICE_FIXTURES[device_name]: - getattr(aioclient_mock, fixture[0])( - f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture("air-purifier-detail-updated.json", DOMAIN), - ) - - def mock_device_response( aioclient_mock: AiohttpClientMocker, device_name: str, override: Any ) -> None: diff --git a/tests/components/vesync/test_platform.py b/tests/components/vesync/test_platform.py deleted file mode 100644 index 85ab3395263ae8..00000000000000 --- a/tests/components/vesync/test_platform.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for the coordinator.""" - -from datetime import timedelta - -from freezegun.api import FrozenDateTimeFactory - -from homeassistant.components.vesync.const import DOMAIN, UPDATE_INTERVAL -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant - -from .common import ( - mock_air_purifier_400s_update_response, - mock_device_response, - mock_multiple_device_responses, - mock_outlet_energy_response, -) - -from tests.common import MockConfigEntry, async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker - - -async def test_entity_update( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test Vesync coordinator data update. - - This test sets up a single device `Air Purifier 400s` and then updates it via the coordinator. - """ - - config_data = {CONF_PASSWORD: "username", CONF_USERNAME: "password"} - config_entry = MockConfigEntry( - data=config_data, - domain=DOMAIN, - unique_id="vesync_unique_id_1", - entry_id="1", - ) - - mock_multiple_device_responses(aioclient_mock, ["Air Purifier 400s", "Outlet"]) - mock_outlet_energy_response(aioclient_mock, "Outlet") - - expected_entities = [ - # From "Air Purifier 400s" - "fan.air_purifier_400s", - "sensor.air_purifier_400s_filter_lifetime", - "sensor.air_purifier_400s_air_quality", - "sensor.air_purifier_400s_pm2_5", - # From Outlet - "switch.outlet", - "sensor.outlet_current_power", - "sensor.outlet_energy_use_today", - "sensor.outlet_energy_use_weekly", - "sensor.outlet_energy_use_monthly", - "sensor.outlet_energy_use_yearly", - "sensor.outlet_current_voltage", - ] - - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - for entity_id in expected_entities: - assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - - assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "5" - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "excellent" - assert hass.states.get("sensor.outlet_current_voltage").state == "120.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0.0" - - # Update the mock responses - aioclient_mock.clear_requests() - mock_air_purifier_400s_update_response(aioclient_mock) - mock_device_response(aioclient_mock, "Outlet", {"voltage": 129}) - mock_outlet_energy_response(aioclient_mock, "Outlet", {"totalEnergy": 2.2}) - - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) - async_fire_time_changed(hass) - await hass.async_block_till_done(True) - - assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "15" - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "good" - assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0.0" - - # energy history only updates once every 6 hours. - freezer.tick(timedelta(hours=6)) - async_fire_time_changed(hass) - await hass.async_block_till_done(True) - - assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "15" - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "good" - assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "2.2" From 404f95b442e3c4c4cdf9e0fe2cdf63320f9a57a0 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 3 Oct 2025 15:04:35 +0300 Subject: [PATCH 3/5] Add Shelly support for valve entities (#153348) --- homeassistant/components/shelly/const.py | 4 + homeassistant/components/shelly/entity.py | 4 + homeassistant/components/shelly/valve.py | 107 ++++++++++++- tests/components/shelly/test_valve.py | 173 +++++++++++++++++++++- 4 files changed, 284 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 8732d272ffcacd..5378177bb3c634 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -308,3 +308,7 @@ class BLEScannerMode(StrEnum): MAX_SCRIPT_SIZE = 5120 All_LIGHT_TYPES = ("cct", "light", "rgb", "rgbw") + +# Shelly-X specific models +MODEL_NEO_WATER_VALVE = "NeoWaterValve" +MODEL_FRANKEVER_WATER_VALVE = "WaterValve" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index f9c0288fa50aba..ebb2d8ca353abc 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -186,6 +186,9 @@ def async_setup_rpc_attribute_entities( for key in key_instances: # Filter non-existing sensors + if description.models and coordinator.model not in description.models: + continue + if description.role and description.role != coordinator.device.config[ key ].get("role", "generic"): @@ -316,6 +319,7 @@ class RpcEntityDescription(EntityDescription): options_fn: Callable[[dict], list[str]] | None = None entity_class: Callable | None = None role: str | None = None + models: set[str] | None = None @dataclass(frozen=True) diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index b748172ba3de70..65fbfa79b4d9c6 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -17,11 +17,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry +from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE +from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, + RpcEntityDescription, ShellyBlockAttributeEntity, + ShellyRpcAttributeEntity, async_setup_block_attribute_entities, + async_setup_entry_rpc, ) from .utils import async_remove_shelly_entity, get_device_entry_gen @@ -33,6 +37,11 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): """Class to describe a BLOCK valve.""" +@dataclass(kw_only=True, frozen=True) +class RpcValveDescription(RpcEntityDescription, ValveEntityDescription): + """Class to describe a RPC virtual valve.""" + + GAS_VALVE = BlockValveDescription( key="valve|valve", name="Valve", @@ -41,6 +50,83 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): ) +class RpcShellyBaseWaterValve(ShellyRpcAttributeEntity, ValveEntity): + """Base Entity for RPC Shelly Water Valves.""" + + entity_description: RpcValveDescription + _attr_device_class = ValveDeviceClass.WATER + _id: int + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize RPC water valve.""" + super().__init__(coordinator, key, attribute, description) + self._attr_name = None # Main device entity + + +class RpcShellyWaterValve(RpcShellyBaseWaterValve): + """Entity that controls a valve on RPC Shelly Water Valve.""" + + _attr_supported_features = ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION + ) + _attr_reports_position = True + + @property + def current_valve_position(self) -> int: + """Return current position of valve.""" + return cast(int, self.attribute_value) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.coordinator.device.number_set(self._id, position) + + +class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve): + """Entity that controls a valve on RPC Shelly NEO Water Valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed or not.""" + return not self.attribute_value + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open valve.""" + await self.coordinator.device.boolean_set(self._id, True) + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + await self.coordinator.device.boolean_set(self._id, False) + + +RPC_VALVES: dict[str, RpcValveDescription] = { + "water_valve": RpcValveDescription( + key="number", + sub_key="value", + role="position", + entity_class=RpcShellyWaterValve, + models={MODEL_FRANKEVER_WATER_VALVE}, + ), + "neo_water_valve": RpcValveDescription( + key="boolean", + sub_key="value", + role="state", + entity_class=RpcShellyNeoWaterValve, + models={MODEL_NEO_WATER_VALVE}, + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, @@ -48,7 +134,24 @@ async def async_setup_entry( ) -> None: """Set up valves for device.""" if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: - async_setup_block_entry(hass, config_entry, async_add_entities) + return async_setup_block_entry(hass, config_entry, async_add_entities) + + return async_setup_rpc_entry(hass, config_entry, async_add_entities) + + +@callback +def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + coordinator = config_entry.runtime_data.rpc + assert coordinator + + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_VALVES, RpcShellyWaterValve + ) @callback diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 7bf9e3b5f1abd3..adb6559ee10fb7 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -1,12 +1,27 @@ """Tests for Shelly valve platform.""" +from copy import deepcopy from unittest.mock import Mock from aioshelly.const import MODEL_GAS import pytest -from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE +from homeassistant.components.shelly.const import ( + MODEL_FRANKEVER_WATER_VALVE, + MODEL_NEO_WATER_VALVE, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + ValveState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -64,3 +79,157 @@ async def test_block_device_gas_valve( assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSED + + +async def test_rpc_water_valve( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device Shelly Water Valve.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": "Position", + "min": 0, + "max": 100, + "meta": {"ui": {"step": 10, "view": "slider", "unit": "%"}}, + "role": "position", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": 0} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_FRANKEVER_WATER_VALVE) + entity_id = "valve.test_name" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-number:200-water_valve" + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Open valve + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 100) + + status["number:200"] = {"value": 100} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + + # Close valve + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 0) + + status["number:200"] = {"value": 0} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Set valve position to 50% + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 50) + + status["number:200"] = {"value": 50} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 50 + + +async def test_rpc_neo_water_valve( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device Shelly NEO Water Valve.""" + config = deepcopy(mock_rpc_device.config) + config["boolean:200"] = { + "name": "State", + "meta": {"ui": {"view": "toggle"}}, + "role": "state", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["boolean:200"] = {"value": False} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_NEO_WATER_VALVE) + entity_id = "valve.test_name" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-boolean:200-neo_water_valve" + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Open valve + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + + status["boolean:200"] = {"value": True} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + + # Close valve + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.boolean_set.assert_called_once_with(200, False) + + status["boolean:200"] = {"value": False} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED From 4ff5462cc4765d5c67cd50dac5a36eb8b283adde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Fri, 3 Oct 2025 15:50:20 +0200 Subject: [PATCH 4/5] Bump Airthings BLE to 1.1.1 (#153529) --- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index fe2cc0eeb36b0e..5ac0b27e26f4b4 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.9.2"] + "requirements": ["airthings-ble==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ead88e99b3245..cefc93148c875e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -456,7 +456,7 @@ airly==1.1.0 airos==0.5.4 # homeassistant.components.airthings_ble -airthings-ble==0.9.2 +airthings-ble==1.1.1 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7f71ee6fb9059..431a13462d0e9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -438,7 +438,7 @@ airly==1.1.0 airos==0.5.4 # homeassistant.components.airthings_ble -airthings-ble==0.9.2 +airthings-ble==1.1.1 # homeassistant.components.airthings airthings-cloud==0.2.0 From d595ec8a07ee1a0dd1dddb783e4785c2fe351097 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Oct 2025 10:46:12 -0400 Subject: [PATCH 5/5] Z-Wave to support migrating from USB to socket with same home ID (#153522) --- .../components/zwave_js/config_flow.py | 16 ++- tests/components/zwave_js/test_config_flow.py | 117 +++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 944c15e7081f4e..1909384639d3cc 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -918,7 +918,7 @@ async def async_step_finish_addon_setup_user( discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.source in (SOURCE_USB, SOURCE_ESPHOME): + if not self.unique_id or self.source == SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( @@ -942,7 +942,12 @@ async def async_step_finish_addon_setup_user( CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, - } + }, + error=( + "migration_successful" + if self.source in (SOURCE_USB, SOURCE_ESPHOME) + else "already_configured" + ), ) return self._async_create_entry_from_vars() @@ -1490,6 +1495,8 @@ async def async_step_esphome( ) # Only update existing entries that are configured via sockets and existing_entry.data.get(CONF_SOCKET_PATH) + # And use the add-on + and existing_entry.data.get(CONF_USE_ADDON) ): await self._async_set_addon_config( {CONF_ADDON_SOCKET: discovery_info.socket_path} @@ -1498,6 +1505,11 @@ async def async_step_esphome( self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) return self.async_abort(reason="already_configured") + # We are not aborting if home ID configured here, we just want to make sure that it's set + # We will update a USB based config entry automatically in `async_step_finish_addon_setup_user` + await self.async_set_unique_id( + str(discovery_info.zwave_home_id), raise_on_progress=False + ) self.socket_path = discovery_info.socket_path self.context["title_placeholders"] = { CONF_NAME: f"{discovery_info.name} via ESPHome" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 1345247b092461..c3dda537db03d7 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1303,7 +1303,11 @@ async def test_esphome_discovery_already_configured( entry = MockConfigEntry( entry_id="mock-entry-id", domain=DOMAIN, - data={CONF_SOCKET_PATH: "esphome://existing-device:6053"}, + data={ + CONF_SOCKET_PATH: "esphome://existing-device:6053", + "use_addon": True, + "integration_created_addon": True, + }, title=TITLE, unique_id="1234", ) @@ -1333,6 +1337,117 @@ async def test_esphome_discovery_already_configured( ) +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +async def test_esphome_discovery_usb_same_home_id( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: + """Test ESPHome discovery works if USB stick with same home ID is configured.""" + entry = MockConfigEntry( + entry_id="mock-entry-id", + domain=DOMAIN, + data={ + CONF_USB_PATH: "/dev/ttyUSB0", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + + assert result["step_id"] == "install_addon" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + ) + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": True, + } + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant,