From 0a758eec4ec08b6ebeb060a656b3e61757d9cd46 Mon Sep 17 00:00:00 2001 From: Hamish Date: Fri, 17 Jul 2026 19:01:36 +0930 Subject: [PATCH 01/21] Add reconfigure flow to Gatus (#176646) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Erwin Douna --- homeassistant/components/gatus/config_flow.py | 82 +++++++++---- .../components/gatus/quality_scale.yaml | 2 +- homeassistant/components/gatus/strings.json | 13 +- tests/components/gatus/test_config_flow.py | 115 ++++++++++++++---- 4 files changed, 158 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py index 972f200abae7e..8abba8d95641c 100644 --- a/homeassistant/components/gatus/config_flow.py +++ b/homeassistant/components/gatus/config_flow.py @@ -46,33 +46,25 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_URL] = str( + URL(user_input[CONF_URL]) + .with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: - url = URL(user_input[CONF_URL]) - except ValueError: - errors["base"] = "invalid_url" + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" else: - if url.scheme not in {"http", "https"} or not url.host: - errors["base"] = "invalid_url" - else: - normalized_url = str( - url.with_query(None) - .with_fragment(None) - .with_user(None) - .with_password(None) - ).rstrip("/") - user_input[CONF_URL] = normalized_url - - self._async_abort_entries_match({CONF_URL: normalized_url}) - - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception during Gatus setup") - errors["base"] = "unknown" - else: - return self.async_create_entry(title="Gatus", data=user_input) + return self.async_create_entry(title="Gatus", data=user_input) return self.async_show_form( step_id="user", @@ -82,6 +74,46 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + user_input[CONF_URL] = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus reconfigure") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index 3d9207ece6b97..dab6799b0a88b 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -76,7 +76,7 @@ rules: icon-translations: status: exempt comment: Entities use the connectivity device class for their icon and define no custom icons. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration does not require user intervention repairs. diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json index 6f6610ddbb017..413dc7180c911 100644 --- a/homeassistant/components/gatus/strings.json +++ b/homeassistant/components/gatus/strings.json @@ -1,14 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_url": "Please enter a valid absolute URL (e.g., http://192.168.1.50:8080)", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::gatus::config::step::user::data_description::url%]" + }, + "description": "[%key:component::gatus::config::step::user::description%]" + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py index 45fbc09afe8c1..45d9bf8c12410 100644 --- a/tests/components/gatus/test_config_flow.py +++ b/tests/components/gatus/test_config_flow.py @@ -61,38 +61,87 @@ async def test_form_success_with_path( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_url( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_gatus_client: AsyncMock +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, ) -> None: - """Test handling of a malformed URL and subsequent recovery.""" + """Test handling validation failures and ensuring the flow can completely recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_URL: "gatus.example.com"}, + {CONF_URL: "http://gatus.example.com:8080"}, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_url"} + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_URL: "http://gatus.example.com:abc"}, + {CONF_URL: "http://gatus.example.com:8080"}, ) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_url"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: "http://gatus.example.com:8080"}, ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080/"}, + ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } @pytest.mark.parametrize( @@ -102,23 +151,24 @@ async def test_form_invalid_url( (Exception("Unexpected backend explosion"), "unknown"), ], ) -async def test_form_failures_and_recovery( +async def test_flow_reconfigure_errors( hass: HomeAssistant, - mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, mock_gatus_client: AsyncMock, side_effect: Exception, error_key: str, ) -> None: - """Test handling validation failures and ensuring the flow can completely recover.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + """Test reconfigure flow errors and recover.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" mock_gatus_client.get_endpoints_statuses.side_effect = side_effect result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_URL: "http://gatus.example.com:8080"}, + {CONF_URL: "http://gatus.example2.com:8080"}, ) assert result["type"] is FlowResultType.FORM @@ -128,26 +178,39 @@ async def test_form_failures_and_recovery( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_URL: "http://gatus.example.com:8080"}, + {CONF_URL: "http://gatus.example2.com:8080"}, ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } -async def test_form_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: - """Test that duplicate configurations for the same base URL abort early.""" + """Test reconfigure flow aborts if the new URL is already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example3.com:8080"}, + entry_id="other_id", ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_URL: "http://gatus.example.com:8080"}, + {CONF_URL: "http://gatus.example3.com:8080"}, ) assert result["type"] is FlowResultType.ABORT From a80f068d748d7e2724292ebad662a944fe6f4b76 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:46:05 +0200 Subject: [PATCH 02/21] Use modern device registry API to remove devices (part 4) (#176673) --- .../components/tankerkoenig/coordinator.py | 4 +--- homeassistant/components/teslemetry/__init__.py | 5 +---- homeassistant/components/tplink_omada/__init__.py | 4 +--- homeassistant/components/tuya/__init__.py | 4 +--- .../components/unifi_access/coordinator.py | 5 +---- homeassistant/components/unifiprotect/migrate.py | 6 +----- homeassistant/components/velbus/__init__.py | 14 +++++--------- homeassistant/components/vistapool/__init__.py | 4 +--- homeassistant/components/xbox/coordinator.py | 4 +--- homeassistant/components/yolink/__init__.py | 4 +--- homeassistant/components/yoto/coordinator.py | 4 +--- homeassistant/components/youtube/__init__.py | 4 +--- 12 files changed, 16 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c8dd1b396dad3..1e47c5344a60e 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -108,9 +108,7 @@ async def async_setup(self) -> None: for station_id in self._selected_stations ): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) if len(self.stations) > 10: _LOGGER.warning( diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index f9815ff9f46d0..7ae42ab703ea3 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -508,10 +508,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - identifier in current_devices for identifier in device_entry.identifiers ): LOGGER.debug("Removing stale device %s", device_entry.id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) entry.runtime_data = TeslemetryData( vehicles=vehicles, diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 559f9eab1851a..a782ae0045385 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -98,9 +98,7 @@ def _remove_old_devices( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None ) if mac and mac not in omada_devices: - device_registry.async_update_device( - registered_device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(registered_device.id) async def async_migrate_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 48a7e5212f002..1dc7709a84a12 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -78,9 +78,7 @@ async def cleanup_device_registry( ): for item in device_entry.identifiers: if item[0] == DOMAIN and item[1] not in device_manager.device_map: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) break diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index adb67b35d27b2..a17989b448713 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -297,10 +297,7 @@ def _remove_stale_devices(self, current_ids: set[str]) -> None: for identifier in device.identifiers ): continue - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 8ed230acdf89f..d2a94eb53b38e 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -148,11 +148,7 @@ def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> N for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): if device.model_id != _AIPORT_DEVICE_TYPE: continue - # Detaching the config entry removes the device (it has no other entry) - # and its entities along with it. - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) @callback diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 4e8f0f0bfc4f8..5cc742925995d 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -138,22 +138,18 @@ async def async_remove_config_entry_device( config_entry: VelbusConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: - """Allow removing a Velbus device and detach its sub-devices. + """Allow removing a Velbus device and its sub-devices. - Sub-devices are detached from this config entry when their parent is - removed. If the device is still on the bus, it may be recreated when - the integration is reloaded or started again. + Sub-devices are removed along with their parent. If the device is still + on the bus, it may be recreated when the integration is reloaded or + started again. """ if config_entry.entry_id not in device_entry.config_entries: return False dev_reg = dr.async_get(hass) for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): if sub_device.via_device_id == device_entry.id: - dev_reg.async_update_device( - sub_device.id, - remove_config_entry_id=config_entry.entry_id, - via_device_id=None, - ) + dev_reg.async_remove_device(sub_device.id) return True diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 1f34877ab9950..d7a01cbd6578f 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -150,9 +150,7 @@ def _async_remove_stale_devices( for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None) if pool_id is not None and pool_id not in valid_pool_ids: - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_initial_refresh( diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 368157a22c2ba..d9b0766d70eb7 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -127,9 +127,7 @@ async def update_data(self) -> dict[str, SmartglassConsole]: and not set(device.identifiers) & identifiers ): _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return {console.id: console for console in consoles.result} diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index a1917c8478702..c2404ea1419c0 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -169,9 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bo identifier[0] == DOMAIN and device_coordinators.get(identifier[1]) is None ): - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py index 026b00eb11ed5..b3d94b1bb4af1 100644 --- a/homeassistant/components/yoto/coordinator.py +++ b/homeassistant/components/yoto/coordinator.py @@ -161,9 +161,7 @@ def _remove_stale_devices(self) -> None: (ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None ) if player_id is not None and player_id not in self.client.players: - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_load_library(self) -> None: """Load the card library and groups; failures only affect browsing.""" diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index dff9652398d7a..b6f12c7efc9c7 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -71,6 +71,4 @@ async def delete_devices( dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for dev_entry in dev_entries: if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): - device_registry.async_update_device( - dev_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(dev_entry.id) From d1b8881603785867f3e7659177aacfe871a72598 Mon Sep 17 00:00:00 2001 From: Arnaud Launay <2205303+alaunay@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:01:04 +0200 Subject: [PATCH 03/21] =?UTF-8?q?change=20suez=20price=20to=20price=20/=20?= =?UTF-8?q?m=C2=B3=20(#176611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/suez_water/sensor.py | 5 +++-- .../components/suez_water/snapshots/test_sensor.ambr | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 262457c4620ef..d7e67ba34fbfa 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -10,6 +10,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant @@ -41,8 +42,8 @@ class SuezWaterSensorEntityDescription(SensorEntityDescription): SuezWaterSensorEntityDescription( key="water_price", translation_key="water_price", - native_unit_of_measurement=CURRENCY_EURO, - device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda suez_data: suez_data.price, ), ) diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index 7cedf7dc476db..53e3394f83a74 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -24,7 +26,7 @@ 'object_id_base': 'Water price', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Water price', 'platform': 'suez_water', @@ -33,16 +35,16 @@ 'supported_features': 0, 'translation_key': 'water_price', 'unique_id': '123456_water_price', - 'unit_of_measurement': '€', + 'unit_of_measurement': '€/m³', }) # --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by toutsurmoneau.fr', - : 'monetary', : 'Suez mock device Water price', - : '€', + : , + : '€/m³', }), 'context': , 'entity_id': 'sensor.suez_mock_device_water_price', From 40e243a1765608b99277a659d53eee0d94cbecac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Alves?= <32654466+luismalves@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:01:53 +0100 Subject: [PATCH 04/21] Add LiteLLM integration (#172960) Co-authored-by: Claude Opus 4.8 (1M context) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/litellm/__init__.py | 33 ++ .../components/litellm/config_flow.py | 253 +++++++++++ homeassistant/components/litellm/const.py | 18 + .../components/litellm/conversation.py | 69 +++ .../components/litellm/coordinator.py | 74 ++++ homeassistant/components/litellm/entity.py | 211 ++++++++++ .../components/litellm/manifest.json | 13 + .../components/litellm/quality_scale.yaml | 98 +++++ homeassistant/components/litellm/strings.json | 55 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + tests/components/litellm/__init__.py | 25 ++ tests/components/litellm/conftest.py | 133 ++++++ .../litellm/snapshots/test_conversation.ambr | 295 +++++++++++++ tests/components/litellm/test_config_flow.py | 397 ++++++++++++++++++ tests/components/litellm/test_conversation.py | 297 +++++++++++++ tests/components/litellm/test_init.py | 61 +++ 21 files changed, 2053 insertions(+) create mode 100644 homeassistant/components/litellm/__init__.py create mode 100644 homeassistant/components/litellm/config_flow.py create mode 100644 homeassistant/components/litellm/const.py create mode 100644 homeassistant/components/litellm/conversation.py create mode 100644 homeassistant/components/litellm/coordinator.py create mode 100644 homeassistant/components/litellm/entity.py create mode 100644 homeassistant/components/litellm/manifest.json create mode 100644 homeassistant/components/litellm/quality_scale.yaml create mode 100644 homeassistant/components/litellm/strings.json create mode 100644 tests/components/litellm/__init__.py create mode 100644 tests/components/litellm/conftest.py create mode 100644 tests/components/litellm/snapshots/test_conversation.ambr create mode 100644 tests/components/litellm/test_config_flow.py create mode 100644 tests/components/litellm/test_conversation.py create mode 100644 tests/components/litellm/test_init.py diff --git a/.strict-typing b/.strict-typing index 8f1239d456655..e7e3bd1c8870d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -350,6 +350,7 @@ homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* +homeassistant.components.litellm.* homeassistant.components.litterrobot.* homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* diff --git a/CODEOWNERS b/CODEOWNERS index 8ce2921396d0c..bfa4aad156b65 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1034,6 +1034,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar +/homeassistant/components/litellm/ @luismalves +/tests/components/litellm/ @luismalves /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet diff --git a/homeassistant/components/litellm/__init__.py b/homeassistant/components/litellm/__init__.py new file mode 100644 index 0000000000000..5447eeb014548 --- /dev/null +++ b/homeassistant/components/litellm/__init__.py @@ -0,0 +1,33 @@ +"""The LiteLLM integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +PLATFORMS = [Platform.CONVERSATION] + + +async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Set up LiteLLM from a config entry.""" + coordinator = LiteLLMDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: LiteLLMConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Unload LiteLLM.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py new file mode 100644 index 0000000000000..0b8df8be1d44a --- /dev/null +++ b/homeassistant/components/litellm/config_flow.py @@ -0,0 +1,253 @@ +"""Config flow for LiteLLM integration.""" + +import logging +from typing import Any, override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import ( + CONF_PROMPT, + DOMAIN, + PLACEHOLDER_API_KEY, + RECOMMENDED_CONVERSATION_OPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the proxy.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate the API key is invalid.""" + + +def _normalize_url(url: str) -> str: + """Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path.""" + parsed = URL(url.strip()) + path = parsed.path.rstrip("/") + if not path.endswith("/v1"): + path = f"{path}/v1" + return str(parsed.with_path(path)) + + +async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]: + """Fetch the available model names from the LiteLLM proxy. + + Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy + serves with the configured model names. + """ + client = AsyncOpenAI( + base_url=url, + api_key=api_key or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + try: + return [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + except (AuthenticationError, PermissionDeniedError) as err: + raise InvalidAuth from err + except OpenAIError as err: + raise CannotConnect from err + + +class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LiteLLM.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + url = _normalize_url(user_input[CONF_URL]) + api_key = user_input.get(CONF_API_KEY) + self._async_abort_entries_match({CONF_URL: url}) + try: + await _get_models(self.hass, url, api_key) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = {CONF_URL: url} + if api_key: + data[CONF_API_KEY] = api_key + return self.async_create_entry( + title=URL(url).host or url, + data=data, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class LiteLLMSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for LiteLLM.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + + async def _fetch_models(self) -> None: + """Fetch models from the LiteLLM proxy.""" + entry = self._get_entry() + self.models = await _get_models( + self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY) + ) + + +class ConversationFlowHandler(LiteLLMSubentryFlowHandler): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self.options: dict[str, Any] = {} + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a conversation agent.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + title=user_input[CONF_MODEL], + data=user_input, + ) + + try: + await self._fetch_models() + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [SelectOptionDict(value=model, label=model) for model in self.models] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API): + valid_api_ids = {api["value"] for api in hass_apis} + self.options[CONF_LLM_HASS_API] = [ + api for api in suggested_llm_apis if api in valid_api_ids + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODEL, default=self.options.get(CONF_MODEL) + ): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/litellm/const.py b/homeassistant/components/litellm/const.py new file mode 100644 index 0000000000000..8f645e234519c --- /dev/null +++ b/homeassistant/components/litellm/const.py @@ -0,0 +1,18 @@ +"""Constants for the LiteLLM integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "litellm" +LOGGER = logging.getLogger(__package__) + +# LiteLLM proxies may run without authentication. The OpenAI client requires a +# non-empty API key, so we send a placeholder when the user did not provide one. +PLACEHOLDER_API_KEY = "sk-no-key-required" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/litellm/conversation.py b/homeassistant/components/litellm/conversation.py new file mode 100644 index 0000000000000..c6d979aba8dd7 --- /dev/null +++ b/homeassistant/components/litellm/conversation.py @@ -0,0 +1,69 @@ +"""Conversation support for LiteLLM.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LiteLLMConfigEntry +from .const import DOMAIN +from .entity import LiteLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LiteLLMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [LiteLLMConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity): + """LiteLLM conversation agent.""" + + _attr_name = None + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py new file mode 100644 index 0000000000000..ecd856bf6fd88 --- /dev/null +++ b/homeassistant/components/litellm/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the LiteLLM integration.""" + +from datetime import timedelta +from typing import override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, PLACEHOLDER_API_KEY + +# Ping the proxy hourly while it is reachable, and back off to once a minute +# while it is down so entities recover quickly once it returns. +UPDATE_INTERVAL_CONNECTED = timedelta(hours=1) +UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) + +type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator] + + +class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Own the OpenAI client and track LiteLLM proxy availability.""" + + config_entry: LiteLLMConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + always_update=False, + ) + self.client = AsyncOpenAI( + base_url=config_entry.data[CONF_URL], + api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + + @override + async def _async_update_data(self) -> None: + """Ping the proxy to confirm it is reachable and authenticated.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + try: + async for _ in self.client.with_options(timeout=10.0).models.list(): + break + except (AuthenticationError, PermissionDeniedError) as err: + raise ConfigEntryAuthFailed from err + except OpenAIError as err: + raise UpdateFailed(err) from err + self.update_interval = UPDATE_INTERVAL_CONNECTED + + @callback + @override + def async_set_updated_data(self, data: None) -> None: + """Manually update data and reset to the connected interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + @callback + def mark_connection_error(self) -> None: + """Flag the proxy as unreachable and schedule a quick recheck.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py new file mode 100644 index 0000000000000..dfd37a0e0f76a --- /dev/null +++ b/homeassistant/components/litellm/entity.py @@ -0,0 +1,211 @@ +"""Base entity for LiteLLM.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + + tool_spec = FunctionDefinition( + name=tool.name, + parameters=schema, + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the LiteLLM message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]): + """Base entity for LiteLLM.""" + + _attr_has_entity_name = True + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + super().__init__(entry.runtime_data) + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + coordinator = self.entry.runtime_data + client = coordinator.client + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except (openai.AuthenticationError, openai.PermissionDeniedError) as err: + # Re-check so the proxy is marked unavailable for the auth failure. + await coordinator.async_request_refresh() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.APIConnectionError as err: + coordinator.mark_connection_error() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.OpenAIError as err: + # Reachable but the request failed; keep the entity available. + coordinator.async_set_updated_data(None) + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(None) + break + else: + LOGGER.warning( + "Stopped after %s tool iterations with unresolved tool calls", + MAX_TOOL_ITERATIONS, + ) diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json new file mode 100644 index 0000000000000..595ec0710b38b --- /dev/null +++ b/homeassistant/components/litellm/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "litellm", + "name": "LiteLLM", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@luismalves"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/litellm", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.45.0"] +} diff --git a/homeassistant/components/litellm/quality_scale.yaml b/homeassistant/components/litellm/quality_scale.yaml new file mode 100644 index 0000000000000..4486643696780 --- /dev/null +++ b/homeassistant/components/litellm/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: done + comment: >- + the coordinator polls the proxy hourly for an availability check, backing + off to once a minute while it is unreachable + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: done + comment: >- + the conversation entity follows the coordinator and is marked unavailable + when the proxy cannot be reached + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: the conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litellm/strings.json b/homeassistant/components/litellm/strings.json new file mode 100644 index 0000000000000..c13cf51220806 --- /dev/null +++ b/homeassistant/components/litellm/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.", + "url": "The base URL of your LiteLLM proxy, including the host and port" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2cfe8887bdc9c..861f54cdad7e7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -429,6 +429,7 @@ "lifx", "linkplay", "litejet", + "litellm", "litterrobot", "livisi", "llama_cpp", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1126febd6237b..a4dfb7730d683 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3911,6 +3911,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "litellm": { + "name": "LiteLLM", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "litterrobot": { "name": "Whisker", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6752fcf2621c5..e9ddf4e4135d0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3257,6 +3257,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litellm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5a83382402a28..d512bbfab0d42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1770,6 +1770,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.litellm # homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation diff --git a/tests/components/litellm/__init__.py b/tests/components/litellm/__init__.py new file mode 100644 index 0000000000000..27b3e0e89ba2e --- /dev/null +++ b/tests/components/litellm/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the LiteLLM integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: + """Get the subentry ID for a given type.""" + ids = [ + subentry_id + for subentry_id, subentry in mock_config_entry.subentries.items() + if subentry.subentry_type == subentry_type + ] + if not ids: + raise ValueError(f"No subentry found for type {subentry_type}") + return ids[0] diff --git a/tests/components/litellm/conftest.py b/tests/components/litellm/conftest.py new file mode 100644 index 0000000000000..84ebbac639edb --- /dev/null +++ b/tests/components/litellm/conftest.py @@ -0,0 +1,133 @@ +"""Fixtures for LiteLLM integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage, Model +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +TEST_URL = "http://localhost:4000/v1" + + +async def models_response(*model_ids: str) -> AsyncGenerator[Model]: + """Yield models as the OpenAI client's `models.list()` would.""" + for model_id in model_ids: + yield Model(id=model_id, created=0, object="model", owned_by="litellm") + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.litellm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Return whether the Assist LLM API is enabled for the conversation agent.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="localhost:4000", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="gpt-3.5-turbo", + unique_id=None, + ), + ], + ) + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Mock the OpenAI client used for chat completions.""" + with patch( + "homeassistant.components.litellm.coordinator.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +def mock_models() -> Generator[AsyncMock]: + """Mock the OpenAI client the config flow uses to list proxy models.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-3.5-turbo", "gpt-4") + ) + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr new file mode 100644 index 0000000000000..a905dbed6dcea --- /dev/null +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock_tool_call_id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True].1 + list([ + dict({ + 'content': ''' + You are a helpful assistant. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'What time is it?', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{}', + 'name': 'HassGetCurrentTime', + }), + 'id': 'mock_tool_call_id', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'role': 'tool', + 'tool_call_id': 'mock_tool_call_id', + }), + dict({ + 'content': '12:00 PM', + 'role': 'assistant', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{"param1":"call1"}', + 'name': 'test_tool', + }), + 'id': 'call_call_1', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '"value1"', + 'role': 'tool', + 'tool_call_id': 'call_call_1', + }), + dict({ + 'content': 'I have successfully called the function', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py new file mode 100644 index 0000000000000..ac3a1675ab116 --- /dev/null +++ b/tests/components/litellm/test_config_flow.py @@ -0,0 +1,397 @@ +"""Test the LiteLLM config flow.""" + +from unittest.mock import AsyncMock, patch + +import httpx +from openai import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + PermissionDeniedError, +) +import pytest + +from homeassistant.components.litellm.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import get_subentry_id, setup_integration +from .conftest import TEST_URL, models_response + +from tests.common import MockConfigEntry + +CONVERSATION_MODEL_OPTIONS = [ + {"value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo"}, + {"value": "gpt-4", "label": "gpt-4"}, +] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +@pytest.mark.parametrize( + "url_input", + ["http://localhost:4000", "http://localhost:4000/", TEST_URL, f"{TEST_URL}/"], +) +async def test_full_flow(hass: HomeAssistant, url_input: str) -> None: + """Test the full config flow normalizes the URL and stores the key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url_input, CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == {CONF_URL: TEST_URL, CONF_API_KEY: "bla"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +async def test_full_flow_without_api_key(hass: HomeAssistant) -> None: + """Test the config flow works without an API key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: TEST_URL} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + exception: Exception, + error: str, +) -> None: + """Test we handle errors and can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + ) as mock_get_models: + mock_get_models.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_models.side_effect = None + mock_get_models.return_value = {"gpt-3.5-turbo": {}} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +def _status_error( + error: type[AuthenticationError | PermissionDeniedError], status_code: int +) -> AuthenticationError | PermissionDeniedError: + """Build an OpenAI status error backed by a real httpx response.""" + return error( + response=httpx.Response( + status_code=status_code, request=httpx.Request("GET", TEST_URL) + ), + body=None, + message="error", + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (_status_error(AuthenticationError, 401), "invalid_auth"), + (_status_error(PermissionDeniedError, 403), "invalid_auth"), + (APIConnectionError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + (APITimeoutError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + ], +) +async def test_user_step_proxy_errors( + hass: HomeAssistant, + side_effect: Exception, + error: str, +) -> None: + """Test the user step surfaces errors raised by the OpenAI client.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + side_effect + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry with the same URL already exists.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "other"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert ( + result["data_schema"].schema["model"].config["options"] + == CONVERSATION_MODEL_OPTIONS + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gpt-3.5-turbo" + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } + + +async def test_conversation_agent_model_options( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the model dropdown is populated from the proxy's model list.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-4o", "gpt-5") + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-4o", "label": "gpt-4o"}, + {"value": "gpt-5", "label": "gpt-5"}, + ] + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow aborts on errors fetching models.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + side_effect=exception, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.title == "gpt-4" + assert subentry.data[CONF_MODEL] == "gpt-4" + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + + +async def test_reconfigure_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring aborts when the main entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.parametrize( + ("current_llm_apis", "suggested_llm_apis", "expected_options"), + [ + (["assist"], ["assist"], ["assist"]), + (["non-existent"], [], ["assist"]), + (["assist", "non-existent"], ["assist"], ["assist"]), + ], +) +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_subentry_llm_api_schema( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + current_llm_apis: list[str], + suggested_llm_apis: list[str], + expected_options: list[str], +) -> None: + """Test llm_hass_api field values when reconfiguring a conversation subentry.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + subentry = mock_config_entry.subentries[subentry_id] + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis}, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == suggested_llm_apis + + field_schema = schema[key] + assert field_schema.config + assert [ + opt["value"] for opt in field_schema.config.get("options") + ] == expected_options diff --git a/tests/components/litellm/test_conversation.py b/tests/components/litellm/test_conversation.py new file mode 100644 index 0000000000000..1f3660a3b98d8 --- /dev/null +++ b/tests/components/litellm/test_conversation.py @@ -0,0 +1,297 @@ +"""Tests for the LiteLLM conversation entity.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ToolInput + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + +AGENT_ID = "conversation.gpt_3_5_turbo" + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.litellm.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert "extra_headers" not in call + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an empty choices response raises an error.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an error talking to the API is handled gracefully.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.OpenAIError("boom") + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_connection_error_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test a connection error marks the entity unavailable until it recovers.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost") + ) + ) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + assert result.response.response_type is intent.IntentResponseType.ERROR + + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state == STATE_UNAVAILABLE + + # A successful availability ping restores the entity. + await mock_config_entry.runtime_data.async_request_refresh() + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock_tool_call_id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id=AGENT_ID, + tool_call_id="mock_tool_call_id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + content="12:00 PM", + ) + ) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + mock_openai_client.chat.completions.create.side_effect = ( + ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + assert mock_openai_client.chat.completions.create.call_count == 2 + assert ( + mock_openai_client.chat.completions.create.call_args.kwargs["messages"] + == snapshot + ) diff --git a/tests/components/litellm/test_init.py b/tests/components/litellm/test_init.py new file mode 100644 index 0000000000000..151f783633dc0 --- /dev/null +++ b/tests/components/litellm/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the LiteLLM integration setup.""" + +from unittest.mock import AsyncMock + +import httpx +from openai import APIConnectionError, AuthenticationError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + AuthenticationError( + response=httpx.Response( + status_code=401, request=httpx.Request("GET", "http://localhost") + ), + body=None, + message="invalid api key", + ), + ConfigEntryState.SETUP_ERROR, + ), + (APIConnectionError(request=None), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test that setup handles errors validating the connection.""" + mock_openai_client.with_options.return_value.models.list.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state From 061d5fff940880901b2f3133e0ecb0a5be914d71 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 17 Jul 2026 12:05:02 +0200 Subject: [PATCH 05/21] Fix logging level per Mikrotik (#176655) --- homeassistant/components/mikrotik/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 8855df9351011..8b41b852e3be6 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -339,7 +339,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: _error = api_error if _error is not None: - _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error) + _LOGGER.debug("Mikrotik %s error: %s", entry[CONF_HOST], _error) if "invalid user name or password" in str(_error): raise LoginError from _error raise CannotConnect from _error From ee03b91dceb59f81201dab704c6d99a7e4c5d71e Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Fri, 17 Jul 2026 12:12:25 +0200 Subject: [PATCH 06/21] Mark Netatmo entities unavailable when device or service is unreachable (#176444) --- homeassistant/components/netatmo/button.py | 6 +- homeassistant/components/netatmo/camera.py | 2 + homeassistant/components/netatmo/climate.py | 9 +- .../components/netatmo/coordinator.py | 21 +++- homeassistant/components/netatmo/cover.py | 10 +- homeassistant/components/netatmo/entity.py | 19 ++++ homeassistant/components/netatmo/fan.py | 14 +-- homeassistant/components/netatmo/light.py | 23 +++-- .../components/netatmo/quality_scale.yaml | 2 +- homeassistant/components/netatmo/select.py | 1 + homeassistant/components/netatmo/sensor.py | 55 ++++++++--- homeassistant/components/netatmo/switch.py | 8 +- tests/components/netatmo/test_camera.py | 2 +- tests/components/netatmo/test_init.py | 97 ++++++++++++++++++- tests/components/netatmo/test_light.py | 3 +- tests/components/netatmo/test_switch.py | 59 ++++++++++- 16 files changed, 274 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 3273023e89412..a2f41356e1ce1 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -12,7 +12,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ def _create_entity(netatmo_device: NetatmoDevice) -> None: ) -class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): +class NetatmoCoverPreferredPositionButton(NetatmoReachabilityEntity, ButtonEntity): """Representation of a Netatmo cover preferred position button device.""" _attr_configuration_url = CONF_URL_CONTROL @@ -69,7 +69,7 @@ def __init__(self, netatmo_device: NetatmoDevice) -> None: @override def async_update_callback(self) -> None: """Update the entity's state.""" - # No state to update for button + self.async_write_ha_state() @override async def async_press(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index a05558a975d06..9ae69c6a24351 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -284,6 +284,8 @@ def async_update_callback(self) -> None: self.device.events ) + self.async_write_ha_state() + def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 177ef99a7efe0..0b5fea41517a2 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -290,6 +290,7 @@ def handle_event(self, event: dict) -> None: elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]: self.async_update_callback() self.data_handler.async_force_update(self._signal_name) + return self.async_write_ha_state() return @@ -325,7 +326,6 @@ def handle_event(self, event: dict) -> None: self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE] self.async_update_callback() - self.async_write_ha_state() return @property @@ -414,15 +414,16 @@ async def async_turn_on(self) -> None: @override def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" - return bool(self._connected) + return super().available and bool(self._connected) @callback @override def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: - if self.available: + if self._connected: self._connected = False + self.async_write_ha_state() return self._connected = True @@ -458,6 +459,8 @@ def async_update_callback(self) -> None: self._boilerstatus = module.boiler_status break + self.async_write_ha_state() + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None diff --git a/homeassistant/components/netatmo/coordinator.py b/homeassistant/components/netatmo/coordinator.py index db8523bd5960b..34eb67f3a7ce5 100644 --- a/homeassistant/components/netatmo/coordinator.py +++ b/homeassistant/components/netatmo/coordinator.py @@ -135,6 +135,7 @@ class NetatmoPublisher: subscriptions: set[CALLBACK_TYPE | None] method: str kwargs: dict + available: bool = True class NetatmoDataHandler: @@ -254,19 +255,29 @@ async def async_fetch_data(self, signal_name: str) -> bool: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: + except ( + pyatmo.NoDeviceError, + pyatmo.ApiError, + TimeoutError, + aiohttp.ClientConnectorError, + ) as err: _LOGGER.debug(err) has_error = True - except (TimeoutError, aiohttp.ClientConnectorError) as err: - _LOGGER.debug(err) - return True + self.publisher[signal_name].available = not has_error + self._notify_subscribers(signal_name) + return has_error + def _notify_subscribers(self, signal_name: str) -> None: + """Notify all subscribers of a publisher to update their state.""" for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() - return has_error + def is_signal_available(self, signal_name: str) -> bool: + """Return whether the last fetch for a publisher succeeded.""" + publisher = self.publisher.get(signal_name) + return publisher is None or publisher.available async def subscribe( self, diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 089964e2ab1a7..82c97c0c45c6a 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -17,7 +17,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def _create_entity(netatmo_device: NetatmoDevice) -> None: ) -class NetatmoCover(NetatmoModuleEntity, CoverEntity): +class NetatmoCover(NetatmoReachabilityEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( @@ -105,5 +105,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_closed = self.device.current_position == 0 - self._attr_current_cover_position = self.device.current_position + if self.device.reachable is not False: + self._attr_is_closed = self.device.current_position == 0 + self._attr_current_cover_position = self.device.current_position + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 97a378c203ac7..ae301cb06fc98 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -35,6 +35,15 @@ def __init__(self, data_handler: NetatmoDataHandler) -> None: self._publishers: list[dict[str, Any]] = [] self._attr_extra_state_attributes = {} + @property + @override + def available(self) -> bool: + """Return True if the underlying data publishers are reachable.""" + return super().available and all( + self.data_handler.is_signal_available(publisher[SIGNAL_NAME]) + for publisher in self._publishers + ) + @override async def async_added_to_hass(self) -> None: """Entity created.""" @@ -174,6 +183,16 @@ def device_type(self) -> DeviceType: return self.device.device_type +class NetatmoReachabilityEntity(NetatmoModuleEntity): + """Module entity that is unavailable when its device is unreachable.""" + + @property + @override + def available(self) -> bool: + """Return True unless the device explicitly reports as unreachable.""" + return super().available and self.device.reachable is not False + + class NetatmoWeatherModuleEntity(NetatmoModuleEntity): """Netatmo weather module entity base class.""" diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 6505eeda9eafa..0e4a4eb828a30 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -12,7 +12,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def _create_entity(netatmo_device: NetatmoDevice) -> None: ) -class NetatmoFan(NetatmoModuleEntity, FanEntity): +class NetatmoFan(NetatmoReachabilityEntity, FanEntity): """Representation of a Netatmo fan.""" _attr_preset_modes = ["slow", "fast"] @@ -78,7 +78,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: @override def async_update_callback(self) -> None: """Update the entity's state.""" - if self.device.fan_speed is None: - self._attr_preset_mode = None - return - self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + if self.device.reachable is not False: + if self.device.fan_speed is None: + self._attr_preset_mode = None + else: + self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index d132b0f75876b..2e84133e17074 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -20,7 +20,7 @@ NETATMO_CREATE_LIGHT, ) from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoModuleEntity, NetatmoReachabilityEntity _LOGGER = logging.getLogger(__name__) @@ -124,7 +124,7 @@ def handle_event(self, event: dict) -> None: @override def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" - return bool(self.data_handler.webhook) + return super().available and bool(self.data_handler.webhook) @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -143,9 +143,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: def async_update_callback(self) -> None: """Update the entity's state.""" self._attr_is_on = bool(self.device.floodlight == "on") + self.async_write_ha_state() -class NetatmoLight(NetatmoModuleEntity, LightEntity): +class NetatmoLight(NetatmoReachabilityEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" _attr_name = None @@ -200,10 +201,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on is True - - if (brightness := self.device.brightness) is not None: - # Netatmo uses a range of [0, 100] to control brightness - self._attr_brightness = round(brightness * 2.55) - else: - self._attr_brightness = None + if self.device.reachable is not False: + self._attr_is_on = self.device.on is True + + if (brightness := self.device.brightness) is not None: + # Netatmo uses a range of [0, 100] to control brightness + self._attr_brightness = round(brightness * 2.55) + else: + self._attr_brightness = None + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml index 9896b2e2d8f42..d69785e45207d 100644 --- a/homeassistant/components/netatmo/quality_scale.yaml +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -34,7 +34,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 78492fecd9a99..3527e8ae7dffa 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -132,3 +132,4 @@ def async_update_callback(self) -> None: self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index b96292b245df2..043b7cfb98423 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -62,6 +62,7 @@ ) from .entity import ( NetatmoBaseEntity, + NetatmoDeviceEntity, NetatmoModuleEntity, NetatmoRoomEntity, NetatmoWeatherModuleEntity, @@ -631,7 +632,25 @@ async def add_public_entities(update: bool = True) -> None: await add_public_entities(False) -class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): +class NetatmoLegacyReachableSensor(NetatmoDeviceEntity, SensorEntity): + """Sensor mixin that goes unavailable, keeping its last value, when unreachable.""" + + @callback + def _async_set_unavailable_if_unreachable(self) -> bool: + """Set the entity unavailable and write state when the device is unreachable. + + Returns True when the device is unreachable so callers return early. + """ + device = cast("pyatmo.Module | pyatmo.Room", self.device) + if device.reachable: + return False + if self.available: + self._attr_available = False + self.async_write_ha_state() + return True + + +class NetatmoBaseSensor(NetatmoModuleEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -666,16 +685,11 @@ def async_update_callback(self) -> None: """Update the entity's state (the legacy way).""" # Keep the last known value for these legacy sensors when the device is # unreachable to preserve the historical behavior expected by existing entities. - if not self.device.reachable: - if self.available: - self._attr_available = False - return - - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True - self._attr_native_value = state + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -700,7 +714,7 @@ def __init__( @override def available(self) -> bool: """Return True if entity is available.""" - return ( + return super().available and ( self.device.reachable or getattr( self.device, @@ -792,9 +806,7 @@ def __init__( @override def async_update_callback(self) -> None: """Update the entity's state.""" - if not self.device.reachable: - if self.available: - self._attr_available = False + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True @@ -861,7 +873,7 @@ def async_update_callback(self) -> None: self.async_write_ha_state() -class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): +class NetatmoRoomSensor(NetatmoRoomEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -893,10 +905,11 @@ def __init__( @override def async_update_callback(self) -> None: """Update the entity's state.""" - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -976,6 +989,17 @@ async def async_config_update_callback(self, area: NetatmoArea) -> None: self._signal_name = f"{PUBLIC}-{area.uuid}" self._mode = area.mode self._show_on_map = area.show_on_map + self._publishers = [ + { + "name": PUBLIC, + "lat_ne": area.lat_ne, + "lon_ne": area.lon_ne, + "lat_sw": area.lat_sw, + "lon_sw": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] await self.data_handler.subscribe( PUBLIC, self._signal_name, @@ -1001,6 +1025,7 @@ def async_update_callback(self) -> None: ) self._attr_available = False + self.async_write_ha_state() return if values := [x for x in data.values() if x is not None]: diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 357edb6736853..8a07e7951dcd0 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -12,7 +12,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ def _create_entity(netatmo_device: NetatmoDevice) -> None: ) -class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): +class NetatmoSwitch(NetatmoReachabilityEntity, SwitchEntity): """Representation of a Netatmo switch device.""" _attr_name = None @@ -70,7 +70,9 @@ def __init__( @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on + if self.device.reachable is not False: + self._attr_is_on = self.device.on + self.async_write_ha_state() @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 1bc1542f23dbf..c26e18140ab6c 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -697,7 +697,7 @@ async def test_setup_component_no_devices( """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_no_data(*args, **kwargs): + async def fake_post_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index d97ac9fd641c7..9fbaf006169b8 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -3,9 +3,11 @@ from datetime import timedelta from functools import partial from time import time +from typing import Any from unittest.mock import AsyncMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from pyatmo.const import ALL_SCOPES import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +15,12 @@ from homeassistant.components import cloud, webhook from homeassistant.components.netatmo import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.const import ( + CONF_WEBHOOK_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, @@ -113,7 +120,7 @@ async def test_setup_component_with_config( """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -656,3 +663,89 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("platform", "entity_id", "module_id", "initial_state"), + [ + pytest.param( + "switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch" + ), + pytest.param( + "cover", "cover.entrance_blinds", "0009999992", "closed", id="cover" + ), + pytest.param( + "fan", + "fan.centralized_ventilation_controler", + "12:34:56:00:01:01:01:b1", + "on", + id="fan", + ), + pytest.param( + "light", + "light.unknown_00_11_22_33_00_11_45_fe", + "00:11:22:33:00:11:45:fe", + "off", + id="light", + ), + pytest.param( + "button", + "button.entrance_blinds_preferred_position", + "0009999992", + STATE_UNKNOWN, + id="button", + ), + ], +) +async def test_entity_unavailable_when_device_unreachable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + platform: str, + entity_id: str, + module_id: str, + initial_state: str, +) -> None: + """Test that entities become unavailable when their device is unreachable.""" + reachable = True + + def set_reachable(payload: dict) -> None: + home = payload.get("body", {}).get("home") + if not isinstance(home, dict): + return + for module in home.get("modules", []): + if module.get("id") == module_id: + module["reachable"] = reachable + + async def fake_post(*args: Any, **kwargs: Any): + return await fake_post_request( + hass, *args, msg_callback=set_reachable, **kwargs + ) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == initial_state + + reachable = False + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 4d3d339e4fe72..83fe5a54607ac 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,5 +1,6 @@ """The tests for Netatmo light.""" +from typing import Any from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion @@ -113,7 +114,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_request_no_data(*args, **kwargs): + async def fake_post_request_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index fd7b09daa4f95..259a0703653c5 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,7 +1,12 @@ """The tests for Netatmo switch.""" +from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +import pyatmo +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( @@ -9,13 +14,13 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import selected_platforms, snapshot_platform_entities +from .common import fake_post_request, selected_platforms, snapshot_platform_entities -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entity( @@ -89,3 +94,51 @@ async def test_switch_setup_and_services( ] } ) + + +@pytest.mark.parametrize( + "error", + [TimeoutError, pyatmo.ApiError], + ids=["timeout", "api_error"], +) +async def test_switch_unavailable_on_fetch_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + error: type[Exception], +) -> None: + """Test the switch becomes unavailable when the data cannot be fetched.""" + raise_error = False + + async def fake_post(*args: Any, **kwargs: Any): + if raise_error: + raise error + return await fake_post_request(hass, *args, **kwargs) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["switch"]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + switch_entity = "switch.prise" + assert hass.states.get(switch_entity).state == "on" + + raise_error = True + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(switch_entity).state == STATE_UNAVAILABLE From 26f28685bbdee236b42df786642566a85132c5c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:13:33 +0200 Subject: [PATCH 07/21] Use modern device registry API to remove devices (part 2) (#176671) --- homeassistant/components/honeywell/climate.py | 6 ++---- homeassistant/components/hydrawise/coordinator.py | 10 +++------- homeassistant/components/ituran/coordinator.py | 4 +--- homeassistant/components/liebherr/__init__.py | 5 +---- homeassistant/components/matter/__init__.py | 4 +--- homeassistant/components/melcloud_home/coordinator.py | 4 +--- homeassistant/components/music_assistant/__init__.py | 4 +--- homeassistant/components/nest/__init__.py | 5 +---- homeassistant/components/netgear/__init__.py | 4 +--- homeassistant/components/nobo_hub/__init__.py | 4 +--- homeassistant/components/nordpool/__init__.py | 4 +--- 11 files changed, 14 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3309e89315312..41baaf1a58239 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -152,10 +152,8 @@ def remove_stale_devices( # If device_id is None an invalid device entry was # found for this config entry. If the device_id is not # in existing device ids it's a stale device entry. - # Remove config entry from this device entry in either case. - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + # Remove the device entry in either case. + device_registry.async_remove_device(device_entry.id) class HoneywellUSThermostat(ClimateEntity): diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index c6f19c79e252c..670ad3bcb54d7 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -142,17 +142,13 @@ def _add_remove_zones(self) -> None: if removed_zones := previous_zones - current_zones: LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) for zone_id in removed_zones: - device_registry.async_update_device( - device_id=previous_zones_by_id[zone_id].id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(previous_zones_by_id[zone_id].id) if removed_controllers := previous_controllers - current_controllers: LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) for controller_id in removed_controllers: - device_registry.async_update_device( - device_id=previous_controllers_by_id[controller_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_controllers_by_id[controller_id].id ) if new_controller_ids := current_controllers - previous_controllers: diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py index 2664e3e12d25f..0cd48b263f059 100644 --- a/homeassistant/components/ituran/coordinator.py +++ b/homeassistant/components/ituran/coordinator.py @@ -73,6 +73,4 @@ def _cleanup_removed_vehicles(self, data: dict[str, Vehicle]) -> None: ) for device in device_entries: if not device.identifiers.intersection(account_vehicles): - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 8f596768f1973..577cd1d737ba1 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -106,10 +106,7 @@ async def _async_scan_for_new_devices(_now: datetime) -> None: for device_id in device_ids: if coordinator := data.coordinators.pop(device_id, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[LiebherrCoordinator] = [] diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index de54159ad3b40..304b65315bfdd 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -394,9 +394,7 @@ def _remove_via_devices( devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) for device in devices: if device.via_device_id == device_entry.id: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def async_remove_config_entry_device( diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index f3d4f8ddb4cd9..c24f246d83719 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -102,9 +102,7 @@ def _async_remove_stale_devices(self, current_ids: set[str]) -> None: for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) @override async def _async_update_data(self) -> UserContext: diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index f11d73a6af63c..17f714a45ec2c 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -248,9 +248,7 @@ def handle_player_config_updated(event: MassEvent) -> None: for device in dev_entries: for identifier in device.identifiers: if identifier[0] == DOMAIN and identifier[1] not in player_ids: - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) return True diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fec919b723721..174b8686a4aaa 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -236,10 +236,7 @@ def _update_devices(self, devices: dict[str, Device]) -> None: if device_id in devices: continue _LOGGER.info("Removing stale device entry '%s'", device_id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=self._config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index afc32d4c5be6d..2212644bce602 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -94,9 +94,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> if device_entry.via_device_id is None: router_id = device_entry.id continue # do not remove the router itself - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Remove entities that are no longer tracked entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 1066d306349d3..2529610da6c15 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -137,9 +137,7 @@ def _cleanup_devices(_hub: nobo) -> None: device_registry, entry.entry_id ): if device.identifiers.isdisjoint(expected_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) _cleanup_devices(hub) hub.register_callback(_cleanup_devices) diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 2b744e01d0dad..6937f9f82022f 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -66,6 +66,4 @@ async def cleanup_device( continue LOGGER.debug("Removing device %s", entry.name) - device_reg.async_update_device( - entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(entry.id) From 880fa628f82d863cac87eab8c14e1eb6f831679e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:01 +0200 Subject: [PATCH 08/21] Use modern device registry API to remove devices (part 1) (#176669) --- homeassistant/components/aidot/coordinator.py | 4 +--- homeassistant/components/aladdin_connect/__init__.py | 4 +--- homeassistant/components/bang_olufsen/event.py | 4 +--- homeassistant/components/bring/coordinator.py | 4 +--- homeassistant/components/fritz/coordinator.py | 4 +--- homeassistant/components/fritzbox/coordinator.py | 4 +--- homeassistant/components/growatt_server/__init__.py | 5 +---- homeassistant/components/home_connect/__init__.py | 4 +--- homeassistant/components/homee/__init__.py | 5 +---- 9 files changed, 9 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py index 7ec6a46ecd03c..b751ac4af0f8c 100644 --- a/homeassistant/components/aidot/coordinator.py +++ b/homeassistant/components/aidot/coordinator.py @@ -163,6 +163,4 @@ def _purge_deleted_lists(self) -> None: ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 516988da45107..1e5cf061a6bd6 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -111,6 +111,4 @@ def remove_stale_devices( break if device_id and device_id not in all_device_ids: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a8807a062addf..625b742164ad7 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -62,9 +62,7 @@ async def async_setup_entry( if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { remote.serial_number for remote in remotes }: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async_add_entities(new_entities=entities) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 738d8d187feef..ee3be122bc555 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -176,9 +176,7 @@ def _purge_deleted_lists(self) -> None: ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index fcad98ef9f9e2..aa043e8259938 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -743,9 +743,7 @@ async def async_trigger_cleanup(self) -> None: ): if not any(con in device.connections for con in valid_connections): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(device.id) fritz_data = self.hass.data[FRITZ_DATA_KEY] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 496c04f2e0555..1518ccfaa4df9 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -121,9 +121,7 @@ def cleanup_removed_devices(self, data: FritzboxCoordinatorData) -> None: ): if not set(device.identifiers) & identifiers: LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 4435217a04ad7..abf9118c8fa55 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -453,10 +453,7 @@ async def _async_scan_for_new_devices(_now: datetime.datetime) -> None: for device_sn in device_domain_ids: if coordinator := runtime_data.devices.pop(device_sn, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[GrowattCoordinator] = [] diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 44e475995ffba..c65e96298e019 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -93,9 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) for device in device_entries: if not device.identifiers.intersection(appliances_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 01a7d39953441..dac324ea09ac8 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -105,10 +105,7 @@ async def _connection_update_callback(connected: bool) -> None: ) if not is_node_present: _LOGGER.info("Removing device %s", device.name) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) # Remove device at runtime when node is removed in homee async def _remove_node_callback(node: HomeeNode, add: bool) -> None: From a899b1edcbc202a396f688112614103c0dbddfcc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:25 +0200 Subject: [PATCH 09/21] Use modern device registry API to remove devices (part 3) (#176672) --- homeassistant/components/opower/sensor.py | 4 +--- homeassistant/components/proxmoxve/coordinator.py | 4 +--- homeassistant/components/ptdevices/coordinator.py | 4 +--- homeassistant/components/roborock/__init__.py | 5 +---- homeassistant/components/schlage/coordinator.py | 5 ++--- homeassistant/components/shelly/utils.py | 6 ++---- homeassistant/components/smartthings/__init__.py | 4 +--- homeassistant/components/sunricher_dali/__init__.py | 5 +---- homeassistant/components/swiss_public_transport/__init__.py | 4 +--- 9 files changed, 11 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 3bbaabf3b0f7d..323b2ae28867b 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -287,9 +287,7 @@ def _update_entities() -> None: if entity_entry.config_entry_id != entry.entry_id: continue entity_registry.async_remove(entity_entry.entity_id) - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Prune sensor tracking for accounts that are no longer present if created_sensors: diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index b701fa975a8b7..09b04b21d7a1f 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -354,9 +354,7 @@ def _async_remove_stale_devices(self, data: dict[str, ProxmoxNodeData]) -> None: for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) class ProxmoxSetupError(Exception): diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py index 828034d089bd4..6bb1b141610a7 100644 --- a/homeassistant/components/ptdevices/coordinator.py +++ b/homeassistant/components/ptdevices/coordinator.py @@ -82,8 +82,6 @@ async def _async_update_data(self) -> PTDevicesResponseData: ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing stale device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return data["body"] diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 0472eb35d892a..ada6df9a8469f 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -183,10 +183,7 @@ def _remove_stale_devices( "Removing device: %s because it no longer exists in your account", device.name, ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index f77df01554688..06b9bbb4b3838 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -116,9 +116,8 @@ def _add_remove_locks(self) -> None: if removed_locks := previous_locks - current_locks: LOGGER.debug("Removed locks: %s", ", ".join(removed_locks)) for lock_id in removed_locks: - device_registry.async_update_device( - device_id=previous_locks_by_lock_id[lock_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_locks_by_lock_id[lock_id].id ) if new_lock_ids := current_locks - previous_locks: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9eccf34badc63..a6aebddbc0235 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -916,7 +916,7 @@ def remove_stale_blu_trv_devices( continue LOGGER.debug("Removing stale BLU TRV device %s", device.name) - dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + dev_reg.async_remove_device(device.id) @callback @@ -938,9 +938,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: if any(identifier[0] == DOMAIN for identifier in device.identifiers): LOGGER.debug("Removing empty sub-device %s", device.name) - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) def format_ble_addr(ble_addr: str) -> str: diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 82d8e751498df..1eb9559a2b8f3 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -314,9 +314,7 @@ async def _handle_shutdown(_: Event) -> None: for device_identifier in device_status ): continue - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) return True diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 6a13d3c5d1ef1..c4012e10f5881 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -59,10 +59,7 @@ def _remove_missing_devices( continue if domain_device_ids.isdisjoint(known_device_ids): - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index fe1e92ab6f269..c17a591e64c1a 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -128,9 +128,7 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_update_device( - dev.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(dev.id) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" From b3b2e3563a11614f42196c0011ef4caddbe77454 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:51 +0200 Subject: [PATCH 10/21] Speed up entity_registry.async_entries_for_device (#176653) --- homeassistant/helpers/entity_registry.py | 32 +++++++++--------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 03a42c7cf5b93..092976d6e3821 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1019,32 +1019,24 @@ def get_entries_for_device_id( split devices. """ data = self.data - device_registry = dr.async_get(self._hass) - if device_id in device_registry.devices: - # Fast path: a live device id resolves directly to its own entities + if keys := self._device_id_index.get(device_id): + # Entities are indexed only under real (live or just-removed) device ids, + # never under a composite device id, so a non-empty bucket means the direct + # result is complete and the device registry can be skipped. return [ entry - for key in self._device_id_index.get(device_id, ()) + for key in keys if not (entry := data[key]).disabled_by or include_disabled_entities ] - # A pre-migration composite device id resolves to the entities of the split - # devices it was migrated into. device_id is kept in the list because the slow - # path is also hit for a device that was just removed (no longer in - # device_registry.devices) whose entities still need to be found - e.g. when the - # entity registry prunes the entities of a removed device. - device_ids = [ - device_id, - *( - device.id - for device in device_registry.async_get_devices_for_composite_device_id( - device_id - ) - ), - ] + # No directly indexed entities: device_id may be a pre-migration composite device + # id, which resolves to the entities of the split devices it was migrated into. + device_registry = dr.async_get(self._hass) return [ entry - for a_device_id in device_ids - for key in self._device_id_index.get(a_device_id, ()) + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + for key in self._device_id_index.get(device.id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] From cea06f54ad61b8043e01f9fe24f4f677844aeb42 Mon Sep 17 00:00:00 2001 From: Niels Date: Fri, 17 Jul 2026 12:17:50 +0200 Subject: [PATCH 11/21] Add vibration conditions (#176598) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/vibration/condition.py | 25 +++ .../components/vibration/conditions.yaml | 26 +++ homeassistant/components/vibration/icons.json | 8 + .../components/vibration/strings.json | 28 +++ tests/components/vibration/test_condition.py | 196 ++++++++++++++++++ 5 files changed, 283 insertions(+) create mode 100644 homeassistant/components/vibration/condition.py create mode 100644 homeassistant/components/vibration/conditions.yaml create mode 100644 tests/components/vibration/test_condition.py diff --git a/homeassistant/components/vibration/condition.py b/homeassistant/components/vibration/condition.py new file mode 100644 index 0000000000000..ab43c4593db5b --- /dev/null +++ b/homeassistant/components/vibration/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for vibration.""" + return CONDITIONS diff --git a/homeassistant/components/vibration/conditions.yaml b/homeassistant/components/vibration/conditions.yaml new file mode 100644 index 0000000000000..5f5bb66d8aaa2 --- /dev/null +++ b/homeassistant/components/vibration/conditions.yaml @@ -0,0 +1,26 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json index 009711fd1655a..d51de741bf87d 100644 --- a/homeassistant/components/vibration/icons.json +++ b/homeassistant/components/vibration/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_detected": { + "condition": "mdi:vibrate" + }, + "is_not_detected": { + "condition": "mdi:vibrate-off" + } + }, "triggers": { "cleared": { "trigger": "mdi:vibrate-off" diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json index 3e7d47b8dbfd5..b1b3898251cf5 100644 --- a/homeassistant/components/vibration/strings.json +++ b/homeassistant/components/vibration/strings.json @@ -1,8 +1,36 @@ { "common": { + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, + "conditions": { + "is_detected": { + "description": "Tests if one or more vibration sensors are detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is detected" + }, + "is_not_detected": { + "description": "Tests if one or more vibration sensors are not detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is not detected" + } + }, "title": "Vibration", "triggers": { "cleared": { diff --git a/tests/components/vibration/test_condition.py b/tests/components/vibration/test_condition.py new file mode 100644 index 0000000000000..a81280df8147f --- /dev/null +++ b/tests/components/vibration/test_condition.py @@ -0,0 +1,196 @@ +"""Test vibration conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.is_detected", {}, True, True), + ("vibration.is_not_detected", {}, True, True), + ], +) +async def test_vibration_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_any( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_all( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ( + "condition_key", + "state_matching", + "state_non_matching", + ), + [ + ( + "vibration.is_detected", + STATE_ON, + STATE_OFF, + ), + ( + "vibration.is_not_detected", + STATE_OFF, + STATE_ON, + ), + ], +) +async def test_vibration_condition_excludes_non_vibration_device_class( + hass: HomeAssistant, + condition_key: str, + state_matching: str, + state_non_matching: str, +) -> None: + """Test vibration condition excludes entities without device_class vibration.""" + entity_id_vibration = "binary_sensor.test_vibration" + entity_id_motion = "binary_sensor.test_motion" + + hass.states.async_set( + entity_id_vibration, state_matching, {ATTR_DEVICE_CLASS: "vibration"} + ) + hass.states.async_set( + entity_id_motion, + state_matching, + {ATTR_DEVICE_CLASS: "motion"}, + ) + await hass.async_block_till_done() + + condition_any = await create_target_condition( + hass, + condition=condition_key, + target={CONF_ENTITY_ID: [entity_id_vibration, entity_id_motion]}, + behavior="any", + ) + + assert condition_any.async_check() is True + + hass.states.async_set( + entity_id_vibration, + state_non_matching, + {ATTR_DEVICE_CLASS: "vibration"}, + ) + await hass.async_block_till_done() + + assert condition_any.async_check() is False From 76ae70b792f31c996563c50a9c3c67ebd48b9fb9 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 17 Jul 2026 12:26:32 +0200 Subject: [PATCH 12/21] Add TextSelector in config flow SMA (#176660) --- homeassistant/components/sma/config_flow.py | 84 +++++++++++++-------- tests/components/sma/__init__.py | 1 + tests/components/sma/test_config_flow.py | 10 ++- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 77abd69ac833f..694f4e98a6fb4 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -27,6 +27,11 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS @@ -34,6 +39,39 @@ _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +STEP_DISCOVERY_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + async def validate_input( hass: HomeAssistant, user_input: dict[str, Any], @@ -130,18 +168,9 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string, - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input, ), errors=errors, ) @@ -172,20 +201,14 @@ async def async_step_reconfigure( CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], CONF_GROUP: user_input[CONF_GROUP], + CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_GROUP): vol.In(GROUPS), - } - ), + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input or dict(reconf_entry.data), ), errors=errors, @@ -221,7 +244,12 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, @@ -290,17 +318,9 @@ async def async_step_discovery_confirm( return self.async_show_form( step_id="discovery_confirm", - data_schema=vol.Schema( - { - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_DISCOVERY_CONFIRM_DATA_SCHEMA, + suggested_values=user_input, ), description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 99ae823dd973a..e700daf95c049 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -40,6 +40,7 @@ CONF_SSL: True, CONF_VERIFY_SSL: False, CONF_GROUP: "user", + CONF_PASSWORD: "new_password", } diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 4c26fcb931756..0e4f8e5c68957 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -8,7 +8,13 @@ from homeassistant.components.sma.const import CONF_GROUP, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac @@ -338,6 +344,7 @@ async def test_full_flow_reconfigure( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 @@ -385,6 +392,7 @@ async def test_full_flow_reconfigure_exceptions( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 From e893a22cca647313cc5a195ed5d23af5d7b6ff7c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:29:52 +0200 Subject: [PATCH 13/21] Make repairs not persistent in FRITZ!Box Tools (#176623) --- homeassistant/components/fritz/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 684b5b3cbeb95..139db1a604fd8 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: domain=DOMAIN, issue_id="deprecated_cleanup_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_cleanup_button", translation_placeholders={"removal_version": "2026.11.0"}, @@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) - domain=DOMAIN, issue_id="deprecated_firmware_update_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_firmware_update_button", translation_placeholders={"removal_version": "2026.11.0"}, From 0d3254deb448d2b450c9e58c12c9fd69d668238a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:43:51 +0200 Subject: [PATCH 14/21] Update ruff (#176645) Co-authored-by: Joostlek --- .pre-commit-config.yaml | 2 +- homeassistant/components/emby/media_player.py | 3 +-- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 191130dd5c052..bd15be304e0b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.15.21 hooks: - id: ruff-check args: diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 2e920cf6cadd6..0e214728c82d1 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -18,7 +18,6 @@ CONF_HOST, CONF_PORT, CONF_SSL, - DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) @@ -179,7 +178,7 @@ def supports_remote_control(self): @override def name(self): """Return the name of the device.""" - return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME + return f"Emby {self.device.name}" @property @override diff --git a/pyproject.toml b/pyproject.toml index 76c8700a0ec9a..9a4dfa5693aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -648,7 +648,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.15.20" +required-version = ">=0.15.21" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8ef2ed276ee8b..a4fecbd60d519 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.2 -ruff==0.15.20 +ruff==0.15.21 yamllint==1.38.0 zizmor==1.24.1 From 27ea3799531fdd4c2a093285358b4c699da9a2c4 Mon Sep 17 00:00:00 2001 From: derekcentrico <1930094+derekcentrico@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:37:12 -0400 Subject: [PATCH 15/21] Bump pyairnow to 1.4.0 for AirNow 2026 API endpoints (#176622) --- .../components/airnow/config_flow.py | 3 +- .../components/airnow/coordinator.py | 1 - homeassistant/components/airnow/manifest.json | 2 +- requirements_all.txt | 2 +- .../components/airnow/fixtures/response.json | 78 +++++++++---------- .../airnow/snapshots/test_diagnostics.ambr | 6 +- 6 files changed, 45 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 89ff2a45f9ac2..3a0dfa49742e7 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: lat = data[CONF_LATITUDE] lng = data[CONF_LONGITUDE] - distance = data[CONF_RADIUS] # Check that the provided latitude/longitude provide a response try: - test_data = await client.observations.latLong(lat, lng, distance=distance) + test_data = await client.observations.latLong(lat, lng) except InvalidKeyError as exc: raise InvalidAuth from exc diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index f96c0e66a16e2..020aecec00f84 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -77,7 +77,6 @@ async def _async_update_data(self) -> dict[str, Any]: obs = await self.airnow.observations.latLong( self.latitude, self.longitude, - distance=self.distance, ) except (AirNowError, ClientConnectorError, InvalidJsonError) as error: diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index da1c936b68fb7..fa321fe1a1587 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.3.1"] + "requirements": ["pyairnow==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d512bbfab0d42..e2b2c16e1b289 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2022,7 +2022,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.3.1 +pyairnow==1.4.0 # homeassistant.components.airobot pyairobotrest==0.4.0 diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json index 91029f5531f22..63877e167a90e 100644 --- a/tests/components/airnow/fixtures/response.json +++ b/tests/components/airnow/fixtures/response.json @@ -1,47 +1,47 @@ [ { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "OZONE", + "nowcastAQI": 44, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM2.5", + "nowcastAQI": 37, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM10", + "nowcastAQI": 11, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" } ] diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index d711f9c2eba13..72cb584adc6a3 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -7,15 +7,15 @@ 'Category.Number': 1, 'DateObserved': '2020-12-20', 'HourObserved': 15, - 'Latitude': '**REDACTED**', + 'Latitude': None, 'LocalTimeZone': 'PST', - 'Longitude': '**REDACTED**', + 'Longitude': None, 'O3': 0.048, 'PM10': 12, 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', - 'StateCode': '**REDACTED**', + 'StateCode': '', }), 'entry': dict({ 'data': dict({ From 176c77341da1f6839788c22e3b5f9a25a65d6135 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 14:30:04 +0200 Subject: [PATCH 16/21] Use modern device registry API for device move in wolflink (#176665) --- homeassistant/components/wolflink/__init__.py | 28 +++++----- tests/components/wolflink/test_init.py | 54 ++++++++++++++++++- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index d86047e323dc7..1a94fc7001940 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -13,6 +13,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN, MANUFACTURER from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator @@ -171,22 +172,21 @@ def _reattach_device_to_hub( if device is None: return - device_disabled_by = device.disabled_by - if device_disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: + # The device registry will set the disabled_by flag to None when moving a + # device disabled by CONFIG_ENTRY to an enabled config entry, but we want + # to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and hub_entry.disabled_by is None + ): device_disabled_by = dr.DeviceEntryDisabler.USER - if source_entry.entry_id != hub_entry.entry_id: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - add_config_entry_id=hub_entry.entry_id, - remove_config_entry_id=source_entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - ) + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_config_entry_id=hub_entry.entry_id, + ) for entity_entry in er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 7576967bdf1a3..7b10a4e8989b4 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -11,7 +11,7 @@ from wolf_comm.wolf_client import FetchFailed, ParameterReadError from homeassistant.components.wolflink.const import DOMAIN, MANUFACTURER -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -252,6 +252,58 @@ async def test_migration_merges_duplicate_v1_entries( assert device.config_entries == {surviving.entry_id} +async def test_migration_merge_into_disabled_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an enabled device merged onto a disabled hub entry gets disabled.""" + hub_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + version=2, + minor_version=2, + disabled_by=ConfigEntryDisabler.USER, + ) + hub_entry.add_to_hass(hass) + legacy_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5678", + data={**LEGACY_CONFIG, "device_id": 5678}, + version=1, + minor_version=2, + ) + legacy_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=legacy_entry.entry_id, + identifiers={(DOMAIN, "5678")}, + manufacturer=MANUFACTURER, + name="test-device", + ) + + with patch( + "homeassistant.components.wolflink.WolfClient", + autospec=True, + ) as wolf_mock: + wolf_mock.return_value.fetch_system_list.side_effect = RequestError( + "Unable to connect" + ) + await hass.config_entries.async_setup(legacy_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].entry_id == hub_entry.entry_id + + # The device was reattached to the disabled hub entry, and its disabled + # state now reflects the new owning entry's disabled state. + migrated_device = device_registry.async_get(device.id) + assert migrated_device is not None + assert migrated_device.config_entries == {hub_entry.entry_id} + assert migrated_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + async def test_migration_v1_list_device_id(hass: HomeAssistant) -> None: """Test v1 migration tolerates device_id stored as a list from partial migrations.""" config_entry = MockConfigEntry( From 9a7107d78b8eb72e2296cc5537e5a5d6139deef3 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 17 Jul 2026 14:39:57 +0200 Subject: [PATCH 17/21] Bump python-duco-connectivity to 0.10.0 (#176685) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 49ee92e04bd45..ee7222fe9c28a 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.9.0"], + "requirements": ["python-duco-connectivity==0.10.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index e2b2c16e1b289..a6839b144a72b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.9.0 +python-duco-connectivity==0.10.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 From ddf73de52d1a8e9d067a2903eb49b8204741b98c Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 17 Jul 2026 16:14:19 +0200 Subject: [PATCH 18/21] Add manufacturer, model, and model_id filtering to entity filter selector (#162989) --- homeassistant/helpers/selector.py | 27 +++++++++++++++++++++-- tests/helpers/test_selector.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5936aadf2cd00..268cc67e84902 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -245,6 +245,26 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): model_id: str +ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = ( + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + # Filter on properties of the device the entity belongs to + vol.Optional("device"): DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA, + } + ) +) + + +class EntityWithDeviceFilterSelectorConfig(EntityFilterSelectorConfig, total=False): + """Class to represent an entity selector filter config. + + Adds device filtering on top of the shared entity filter, only used by + the entity selector. + """ + + device: DeviceFilterSelectorConfig + + class ActionSelectorConfig(BaseSelectorConfig): """Class to represent an action selector config.""" @@ -985,7 +1005,10 @@ class EntitySelectorConfig( include_entities: list[str] multiple: bool reorder: bool - filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: ( + EntityWithDeviceFilterSelectorConfig + | list[EntityWithDeviceFilterSelectorConfig] + ) @SELECTORS.register("entity") @@ -1004,7 +1027,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + [ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), } ), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 95bbaaad87fcb..9d8a2e764487b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -300,6 +300,38 @@ def test_device_selector_schema_error(schema) -> None: ( { "filter": [ + { + "device": { + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + } + } + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "domain": "binary_sensor", + "device": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + }, + }, + { + "device": { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + "model_id": "other-mock-model_id", + }, + }, {"unit_of_measurement": "baguette"}, ] }, @@ -341,6 +373,10 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"unit_of_measurement": ["currywurst", "bratwurst"]}, # Invalid unit_of_measurement {"filter": [{"unit_of_measurement": 42}]}, + # Device properties must be grouped under the device key + {"filter": [{"manufacturer": "mock-manuf"}]}, + {"filter": [{"model": "mock-model"}]}, + {"filter": [{"model_id": "mock-model_id"}]}, # reorder can only be used when multiple is true {"reorder": True}, {"reorder": True, "multiple": False}, From cb6a5187de85358c185f8a670ae732a24e21f9da Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 16:48:52 +0200 Subject: [PATCH 19/21] Deprecate no longer working device helpers (#176696) --- homeassistant/helpers/device.py | 35 +++++++++++++----------- tests/helpers/test_device.py | 48 ++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 2d90a9c7914b2..af8e5908661fb 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -3,6 +3,7 @@ from homeassistant.core import HomeAssistant, callback from . import device_registry as dr, entity_registry as er +from .frame import ReportBehavior, report_usage @callback @@ -41,13 +42,18 @@ def async_device_info_to_link_from_entity( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from an entity. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - return async_device_info_to_link_from_device_id( - hass, - async_entity_id_to_device_id(hass, entity_id_or_uuid), + report_usage( + "calls async_device_info_to_link_from_entity, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry = " + "async_entity_id_to_device(hass, source_entity_id) instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback @@ -57,18 +63,17 @@ def async_device_info_to_link_from_device_id( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from a device id. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - dev_reg = dr.async_get(hass) - - if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None: - return None - - return dr.DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, + report_usage( + "calls async_device_info_to_link_from_device_id, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry to the target device instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 262e700c29edf..5459020276c99 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -1,5 +1,7 @@ """Tests for the Device Utils.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -103,7 +105,13 @@ async def test_device_info_to_link( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test for returning device info with device link information.""" + """The link helpers are deprecated and always return None. + + A device_info carrying another device's identifiers implicitly added the caller's + config entry to that device, which a single-config-entry device can't represent - it + would silently fork a duplicate instead. Entities still attach to another config + entry's device by setting entity.device_entry. + """ config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -112,7 +120,6 @@ async def test_device_info_to_link( connections={("mac", "30:31:32:33:34:00")}, config_entry_id=config_entry.entry_id, ) - assert device is not None # Source entity registry source_entity = entity_registry.async_get_or_create( @@ -125,33 +132,30 @@ async def test_device_info_to_link( await hass.async_block_till_done() assert entity_registry.async_get("sensor.test_source") is not None - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid=source_entity.entity_id - ) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } - - result = async_device_info_to_link_from_device_id(hass, device_id=device.id) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } + # No link device_info is returned, even for an existing entity and device + with patch("homeassistant.helpers.device.report_usage") as report_usage: + assert ( + async_device_info_to_link_from_entity( + hass, entity_id_or_uuid=source_entity.entity_id + ) + is None + ) + assert ( + async_device_info_to_link_from_device_id(hass, device_id=device.id) is None + ) + assert report_usage.call_count == 2 # With a non-existent entity id - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid="sensor.invalid" + assert ( + async_device_info_to_link_from_entity(hass, entity_id_or_uuid="sensor.invalid") + is None ) - assert result is None # With a non-existent device id - result = async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") is None # With a None device id - result = async_device_info_to_link_from_device_id(hass, device_id=None) - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id=None) is None async def test_remove_stale_device_links_keep_entity_device( From 1492fc3fd51e0411962d1af10ff45cbceafed7c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 17 Jul 2026 17:05:20 +0200 Subject: [PATCH 20/21] Refactor imou to use entity descriptions (#176675) --- homeassistant/components/imou/button.py | 70 ++++++++++++---------- homeassistant/components/imou/camera.py | 42 +++++++++---- homeassistant/components/imou/entity.py | 9 +-- homeassistant/components/imou/switch.py | 79 +++++++++++++++---------- 4 files changed, 123 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 972dee03f3c35..dd7242ae2bc7c 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -5,7 +5,11 @@ from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,15 +27,6 @@ PARAM_PTZ_LEFT = "ptz_left" PARAM_PTZ_RIGHT = "ptz_right" -BUTTON_TYPES = ( - PARAM_RESTART_DEVICE, - PARAM_MUTE, - PARAM_PTZ_UP, - PARAM_PTZ_DOWN, - PARAM_PTZ_LEFT, - PARAM_PTZ_RIGHT, -) - PTZ_BUTTON_TYPES = ( PARAM_PTZ_UP, PARAM_PTZ_DOWN, @@ -39,20 +34,43 @@ PARAM_PTZ_RIGHT, ) -BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { - PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, -} +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key=PARAM_RESTART_DEVICE, + device_class=ButtonDeviceClass.RESTART, + ), + ButtonEntityDescription( + key=PARAM_MUTE, + translation_key=PARAM_MUTE, + ), + ButtonEntityDescription( + key=PARAM_PTZ_UP, + translation_key=PARAM_PTZ_UP, + ), + ButtonEntityDescription( + key=PARAM_PTZ_DOWN, + translation_key=PARAM_PTZ_DOWN, + ), + ButtonEntityDescription( + key=PARAM_PTZ_LEFT, + translation_key=PARAM_PTZ_LEFT, + ), + ButtonEntityDescription( + key=PARAM_PTZ_RIGHT, + translation_key=PARAM_PTZ_RIGHT, + ), +) def _iter_buttons( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (button_type, device) pairs for supported buttons.""" +) -> list[tuple[ButtonEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported buttons.""" return [ - (button_type, device) + (description, device) for device in coordinator.devices - for button_type in device.buttons - if button_type in BUTTON_TYPES + for description in BUTTON_TYPES + if description.key in device.buttons ] @@ -67,8 +85,8 @@ async def async_setup_entry( def _add_buttons(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouButton(coordinator, button_type, device) - for button_type, device in _iter_buttons(coordinator) + ImouButton(coordinator, description, device) + for description, device in _iter_buttons(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,17 +104,7 @@ def _remove_new_device_callback() -> None: class ImouButton(ImouEntity, ButtonEntity): """Imou button entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou button entity.""" - super().__init__(coordinator, entity_type, device) - if device_class := BUTTON_DEVICE_CLASS.get(entity_type): - self._attr_device_class = device_class - self._attr_translation_key = None + entity_description: ButtonEntityDescription @override async def async_press(self) -> None: diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index a06a413b80b01..79acdedc9c441 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -1,12 +1,17 @@ """Support for Imou camera entities.""" +from dataclasses import dataclass from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,9 +28,25 @@ PYIMOUAPI_LIVE_PROTOCOL = "https" PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3 -CAMERA_TYPES = ( - ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), - ("camera_hd", PARAM_HD), + +@dataclass(frozen=True, kw_only=True) +class ImouCameraEntityDescription(CameraEntityDescription): + """Describes an Imou camera entity.""" + + resolution: str + + +CAMERA_TYPES: tuple[ImouCameraEntityDescription, ...] = ( + ImouCameraEntityDescription( + key="camera_sd", + translation_key="camera_sd", + resolution=CAMERA_STREAM_RESOLUTION_SD, + ), + ImouCameraEntityDescription( + key="camera_hd", + translation_key="camera_hd", + resolution=PARAM_HD, + ), ) @@ -40,11 +61,11 @@ async def async_setup_entry( def _add_cameras(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouCamera(coordinator, entity_type, device, resolution) + ImouCamera(coordinator, description, device) for device in coordinator.devices if device.channel_id is not None if imou_device_identifier(device) in device_keys - for entity_type, resolution in CAMERA_TYPES + for description in CAMERA_TYPES ) coordinator.new_device_callbacks.append(_add_cameras) @@ -61,19 +82,18 @@ def _remove_new_device_callback() -> None: class ImouCamera(ImouEntity, Camera): """Representation of an Imou camera stream.""" + entity_description: ImouCameraEntityDescription _attr_supported_features = CameraEntityFeature.STREAM def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: ImouCameraEntityDescription, device: ImouHaDevice, - resolution: str, ) -> None: """Initialize the camera entity.""" - self._resolution = resolution Camera.__init__(self) - super().__init__(coordinator, entity_type, device) + super().__init__(coordinator, description, device) @override async def stream_source(self) -> str | None: @@ -81,7 +101,7 @@ async def stream_source(self) -> str | None: try: return await self.coordinator.device_manager.async_get_device_stream( self.device, - self._resolution, + self.entity_description.resolution, PYIMOUAPI_LIVE_PROTOCOL, ) except ImouException as err: diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py index ea21763eb946c..e9c25f64dbeba 100644 --- a/homeassistant/components/imou/entity.py +++ b/homeassistant/components/imou/entity.py @@ -5,6 +5,7 @@ from pyimouapi.ha_device import DeviceStatus, ImouHaDevice from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier @@ -19,15 +20,15 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: EntityDescription, device: ImouHaDevice, ) -> None: """Initialize the Imou entity.""" super().__init__(coordinator) - self._entity_type = entity_type + self.entity_description = description + self._entity_type = description.key self._device_key = imou_device_identifier(device) - self._attr_unique_id = f"{self._device_key}${entity_type}" - self._attr_translation_key = entity_type + self._attr_unique_id = f"{self._device_key}${description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_key)}, name=device.channel_name or device.device_name, diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index caed3462950bd..be6b7764127d5 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -5,7 +5,11 @@ from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,32 +31,53 @@ PARALLEL_UPDATES = 0 -SWITCH_TYPES = ( - PARAM_AB_ALARM_SOUND, - PARAM_AUDIO_ENCODE_CONTROL, - PARAM_CLOSE_CAMERA, - PARAM_HEADER_DETECT, - PARAM_LIGHT, - PARAM_MOTION_DETECT, - PARAM_PLUG_SWITCH, - PARAM_WHITE_LIGHT, +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PARAM_AB_ALARM_SOUND, + translation_key=PARAM_AB_ALARM_SOUND, + ), + SwitchEntityDescription( + key=PARAM_AUDIO_ENCODE_CONTROL, + translation_key=PARAM_AUDIO_ENCODE_CONTROL, + ), + SwitchEntityDescription( + key=PARAM_CLOSE_CAMERA, + translation_key=PARAM_CLOSE_CAMERA, + ), + SwitchEntityDescription( + key=PARAM_HEADER_DETECT, + translation_key=PARAM_HEADER_DETECT, + ), + SwitchEntityDescription( + key=PARAM_LIGHT, + translation_key=PARAM_LIGHT, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_MOTION_DETECT, + translation_key=PARAM_MOTION_DETECT, + ), + SwitchEntityDescription( + key=PARAM_PLUG_SWITCH, + translation_key=PARAM_PLUG_SWITCH, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_WHITE_LIGHT, + translation_key=PARAM_WHITE_LIGHT, + ), ) -SWITCH_DEVICE_CLASS: dict[str, SwitchDeviceClass] = { - PARAM_LIGHT: SwitchDeviceClass.SWITCH, - PARAM_PLUG_SWITCH: SwitchDeviceClass.SWITCH, -} - def _iter_switches( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (switch_type, device) pairs for supported switches.""" +) -> list[tuple[SwitchEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported switches.""" return [ - (switch_type, device) + (description, device) for device in coordinator.devices - for switch_type in device.switches - if switch_type in SWITCH_TYPES + for description in SWITCH_TYPES + if description.key in device.switches ] @@ -67,8 +92,8 @@ async def async_setup_entry( def _add_switches(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouSwitch(coordinator, switch_type, device) - for switch_type, device in _iter_switches(coordinator) + ImouSwitch(coordinator, description, device) + for description, device in _iter_switches(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,15 +111,7 @@ def _remove_new_device_callback() -> None: class ImouSwitch(ImouEntity, SwitchEntity): """Imou switch entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou switch entity.""" - super().__init__(coordinator, entity_type, device) - self._attr_device_class = SWITCH_DEVICE_CLASS.get(entity_type) + entity_description: SwitchEntityDescription @property @override From ccf7fcbe6ba00c1b020fea9d826a43fc072cf7ec Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 17 Jul 2026 17:07:20 +0200 Subject: [PATCH 21/21] Bump pyportainer to 1.0.42 (#176694) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 9787cd141e7ce..395fe0b964133 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.38"] + "requirements": ["pyportainer==1.0.42"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6839b144a72b..fd15fa1464120 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2482,7 +2482,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.38 +pyportainer==1.0.42 # homeassistant.components.probe_plus pyprobeplus==1.1.2