From 7fc8da6769c2adf08b0cb7f4d60777e9ee52fe4b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 5 Sep 2025 12:09:37 +0200 Subject: [PATCH 01/90] Add support for migrated Hue bridge (#151411) Co-authored-by: Joostlek --- homeassistant/components/hue/config_flow.py | 80 +++++++- tests/components/hue/test_config_flow.py | 215 +++++++++++++++++++- 2 files changed, 290 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index bec443526138c..3328b5ab65941 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -9,7 +9,9 @@ import aiohttp from aiohue import LinkButtonNotPressed, create_app_key from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp +from aiohue.errors import AiohueException from aiohue.util import normalize_bridge_id +from aiohue.v2 import HueBridgeV2 import slugify as unicode_slug import voluptuous as vol @@ -40,6 +42,9 @@ HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"] HUE_MANUAL_BRIDGE_ID = "manual" +BSB002_MODEL_ID = "BSB002" +BSB003_MODEL_ID = "BSB003" + class HueFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Hue config flow.""" @@ -74,7 +79,14 @@ async def _get_bridge( """Return a DiscoveredHueBridge object.""" try: bridge = await discover_bridge( - host, websession=aiohttp_client.async_get_clientsession(self.hass) + host, + websession=aiohttp_client.async_get_clientsession( + # NOTE: we disable SSL verification for now due to the fact that the (BSB003) + # Hue bridge uses a certificate from a on-bridge root authority. + # We need to specifically handle this case in a follow-up update. + self.hass, + verify_ssl=False, + ), ) except aiohttp.ClientError as err: LOGGER.warning( @@ -110,7 +122,9 @@ async def async_step_init( try: async with asyncio.timeout(5): bridges = await discover_nupnp( - websession=aiohttp_client.async_get_clientsession(self.hass) + websession=aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ) ) except TimeoutError: bridges = [] @@ -178,7 +192,9 @@ async def async_step_link( app_key = await create_app_key( bridge.host, f"home-assistant#{device_name}", - websession=aiohttp_client.async_get_clientsession(self.hass), + websession=aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ), ) except LinkButtonNotPressed: errors["base"] = "register_failed" @@ -228,7 +244,6 @@ async def async_step_zeroconf( self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host}, reload_on_update=True ) - # we need to query the other capabilities too bridge = await self._get_bridge( discovery_info.host, discovery_info.properties["bridgeid"] @@ -236,6 +251,14 @@ async def async_step_zeroconf( if bridge is None: return self.async_abort(reason="cannot_connect") self.bridge = bridge + if ( + bridge.supports_v2 + and discovery_info.properties.get("modelid") == BSB003_MODEL_ID + ): + # try to handle migration of BSB002 --> BSB003 + if await self._check_migrated_bridge(bridge): + return self.async_abort(reason="migrated_bridge") + return await self.async_step_link() async def async_step_homekit( @@ -272,6 +295,55 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu self.bridge = bridge return await self.async_step_link() + async def _check_migrated_bridge(self, bridge: DiscoveredHueBridge) -> bool: + """Check if the discovered bridge is a migrated bridge.""" + # Try to handle migration of BSB002 --> BSB003. + # Once we detect a BSB003 bridge on the network which has not yet been + # configured in HA (otherwise we would have had a unique id match), + # we check if we have any existing (BSB002) entries and if we can connect to the + # new bridge with our previously stored api key. + # If that succeeds, we migrate the entry to the new bridge. + for conf_entry in self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ): + if conf_entry.data[CONF_API_VERSION] != 2: + continue + if conf_entry.data[CONF_HOST] == bridge.host: + continue + # found an existing (BSB002) bridge entry, + # check if we can connect to the new BSB003 bridge using the old credentials + api = HueBridgeV2(bridge.host, conf_entry.data[CONF_API_KEY]) + try: + await api.fetch_full_state() + except (AiohueException, aiohttp.ClientError): + continue + old_bridge_id = conf_entry.unique_id + assert old_bridge_id is not None + # found a matching entry, migrate it + self.hass.config_entries.async_update_entry( + conf_entry, + data={ + **conf_entry.data, + CONF_HOST: bridge.host, + }, + unique_id=bridge.id, + ) + # also update the bridge device + dev_reg = dr.async_get(self.hass) + if bridge_device := dev_reg.async_get_device( + identifiers={(DOMAIN, old_bridge_id)} + ): + dev_reg.async_update_device( + bridge_device.id, + # overwrite identifiers with new bridge id + new_identifiers={(DOMAIN, bridge.id)}, + # overwrite mac addresses with empty set to drop the old (incorrect) addresses + # this will be auto corrected once the integration is loaded + new_connections=set(), + ) + return True + return False + class HueV1OptionsFlowHandler(OptionsFlow): """Handle Hue options for V1 implementation.""" diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index e4bdda422d1c8..bc63343f9bef9 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP -from aiohue.errors import LinkButtonNotPressed +from aiohue.errors import AiohueException, LinkButtonNotPressed import pytest import voluptuous as vol @@ -732,3 +732,216 @@ async def test_bridge_connection_failed( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_bsb003_bridge_discovery( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(const.DOMAIN, "bsb002_00000")}, + connections={(dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF")}, + ) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb002_00000"), ("192.168.1.218", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.return_value = {} + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migrated_bridge" + + migrated_device = device_registry.async_get(device.id) + + assert migrated_device is not None + assert len(migrated_device.identifiers) == 1 + assert list(migrated_device.identifiers)[0] == (const.DOMAIN, "bsb003_00000") + # The tests don't add new connection, but that will happen + # outside of the config flow + assert len(migrated_device.connections) == 0 + assert entry.data["host"] == "192.168.1.218" + + +async def test_bsb003_bridge_discovery_old_version( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 1, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +async def test_bsb003_bridge_discovery_same_host( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +@pytest.mark.parametrize("exception", [AiohueException, ClientError]) +async def test_bsb003_bridge_discovery_cannot_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + exception: Exception, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.side_effect = exception + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" From b6c9217429c590f63c7fd10e86a6f0d822b95846 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:25:25 -0700 Subject: [PATCH 02/90] Add missing device trigger duration localizations (#151578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/fan/strings.json | 3 +++ homeassistant/components/light/strings.json | 3 ++- homeassistant/components/remote/strings.json | 3 +++ homeassistant/components/switch/strings.json | 3 +++ homeassistant/components/update/strings.json | 3 +++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index c4951e88c91b2..485d6aa4b59ee 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -14,6 +14,9 @@ "toggle": "[%key:common::device_automation::action_type::toggle%]", "turn_on": "[%key:common::device_automation::action_type::turn_on%]", "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 7a53f2569e781..a17d6793b83e2 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -57,7 +57,8 @@ }, "extra_fields": { "brightness_pct": "Brightness", - "flash": "Flash" + "flash": "Flash", + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 09b270b968766..0c6cf98de7f3f 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -14,6 +14,9 @@ "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index b73cf8f849dcc..be5aa09cf342b 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -14,6 +14,9 @@ "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 5194965cf69f7..a90f5c8a998b1 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -5,6 +5,9 @@ "changed_states": "{entity_name} update availability changed", "turned_on": "{entity_name} got an update available", "turned_off": "{entity_name} became up-to-date" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { From d90f2a1de140f76e3c11f24efd3c7d38f671ff92 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:23:48 +0200 Subject: [PATCH 03/90] Correct capitalization of "FRITZ!Box" in FRITZ!Box Tools integration (#151637) --- homeassistant/components/fritz/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 45d66e9621b65..5d5aba2af6036 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -183,8 +183,8 @@ "description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.", "fields": { "device_id": { - "name": "Fritz!Box Device", - "description": "Select the Fritz!Box to configure." + "name": "FRITZ!Box Device", + "description": "Select the FRITZ!Box to configure." }, "password": { "name": "[%key:common::config_flow::data::password%]", From 3dacffaaf93ea0296f370c6f3c061642f7e8e67b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 4 Sep 2025 03:33:43 -0400 Subject: [PATCH 04/90] Fix Sonos Dialog Select type conversion (#151649) --- homeassistant/components/sonos/select.py | 11 +++++++++-- homeassistant/components/sonos/speaker.py | 7 ++++++- tests/components/sonos/test_select.py | 8 ++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py index 052a1d8796763..0a56e37e75c0f 100644 --- a/homeassistant/components/sonos/select.py +++ b/homeassistant/components/sonos/select.py @@ -61,8 +61,15 @@ def available_soco_attributes( if ( state := getattr(speaker.soco, select_data.soco_attribute, None) ) is not None: - setattr(speaker, select_data.speaker_attribute, state) - features.append(select_data) + try: + setattr(speaker, select_data.speaker_attribute, int(state)) + features.append(select_data) + except ValueError: + _LOGGER.error( + "Invalid value for %s %s", + select_data.speaker_attribute, + state, + ) return features async def _async_create_entities(speaker: SonosSpeaker) -> None: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 427f02f047989..acf1b08cd36ed 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -599,7 +599,12 @@ def async_update_volume(self, event: SonosEvent) -> None: for enum_var in (ATTR_DIALOG_LEVEL,): if enum_var in variables: - setattr(self, f"{enum_var}_enum", variables[enum_var]) + try: + setattr(self, f"{enum_var}_enum", int(variables[enum_var])) + except ValueError: + _LOGGER.error( + "Invalid value for %s %s", enum_var, variables[enum_var] + ) self.async_write_entity_states() diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py index ada48de21f396..dbbf28a52d74a 100644 --- a/tests/components/sonos/test_select.py +++ b/tests/components/sonos/test_select.py @@ -38,9 +38,9 @@ async def platform_binary_sensor_fixture(): [ (0, "off"), (1, "low"), - (2, "medium"), - (3, "high"), - (4, "max"), + ("2", "medium"), + ("3", "high"), + ("4", "max"), ], ) async def test_select_dialog_level( @@ -49,7 +49,7 @@ async def test_select_dialog_level( soco, entity_registry: er.EntityRegistry, speaker_info: dict[str, str], - level: int, + level: int | str, result: str, ) -> None: """Test dialog level select entity.""" From beec6e86e0dc8018e5d77174fabb2db811699165 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Thu, 4 Sep 2025 04:30:23 -0300 Subject: [PATCH 05/90] Fix WebSocket proxy for add-ons not forwarding ping/pong frame data (#151654) --- homeassistant/components/hassio/ingress.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index e1f96b76bcb1a..2938de927210d 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -303,9 +303,9 @@ async def _websocket_forward( elif msg.type is aiohttp.WSMsgType.BINARY: await ws_to.send_bytes(msg.data) elif msg.type is aiohttp.WSMsgType.PING: - await ws_to.ping() + await ws_to.ping(msg.data) elif msg.type is aiohttp.WSMsgType.PONG: - await ws_to.pong() + await ws_to.pong(msg.data) elif ws_to.closed: await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type] except RuntimeError: From 85b6adcc9a083d08ff67fcd403e3af457793b065 Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Fri, 5 Sep 2025 12:11:44 +0200 Subject: [PATCH 06/90] Fix, entities stay unavailable after timeout error, Imeon inverter integration (#151671) Co-authored-by: TheBushBoy --- homeassistant/components/imeon_inverter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index a9a37f3fd9c7c..837b735124175 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.14"], + "requirements": ["imeon_inverter_api==0.3.16"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/requirements_all.txt b/requirements_all.txt index 37e728130d76e..9e5ff5a2ab9e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.14 +imeon_inverter_api==0.3.16 # homeassistant.components.imgw_pib imgw_pib==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a294ba2d468ef..f7144f8bf10ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1075,7 +1075,7 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.14 +imeon_inverter_api==0.3.16 # homeassistant.components.imgw_pib imgw_pib==1.5.4 From 8710267d53215d5629dab37e3a82e13699eac525 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 4 Sep 2025 14:44:10 +0200 Subject: [PATCH 07/90] Bump aiohue to 4.7.5 (#151684) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 8bc3d84bd5039..e6f431727d019 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.7.4"], + "requirements": ["aiohue==4.7.5"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9e5ff5a2ab9e0..2a2a262f6b31f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -277,7 +277,7 @@ aiohomekit==3.2.15 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.4 +aiohue==4.7.5 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7144f8bf10ad..d2ee0038e50fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -262,7 +262,7 @@ aiohomekit==3.2.15 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.4 +aiohue==4.7.5 # homeassistant.components.imap aioimaplib==2.0.1 From fcc3f92f8c7b7501f0bc3523a44b5d454eb7392c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 4 Sep 2025 17:04:49 +0200 Subject: [PATCH 08/90] Update frontend to 20250903.3 (#151694) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index becab5a18c515..d74bf1f30b7f9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250903.2"] + "requirements": ["home-assistant-frontend==20250903.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 50acadce8088a..b43da76a609fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==5.3.0 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250903.2 +home-assistant-frontend==20250903.3 home-assistant-intents==2025.9.3 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2a2a262f6b31f..395da3579d681 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1178,7 +1178,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.2 +home-assistant-frontend==20250903.3 # homeassistant.components.conversation home-assistant-intents==2025.9.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2ee0038e50fd..bf3f71baa7d7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.2 +home-assistant-frontend==20250903.3 # homeassistant.components.conversation home-assistant-intents==2025.9.3 From bfdd2053baeb4b65fca913aa7bd3a0fdc306f413 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Fri, 5 Sep 2025 06:37:40 +0100 Subject: [PATCH 09/90] Require OhmeAdvancedSettingsCoordinator to run regardless of entities (#151701) --- homeassistant/components/ohme/coordinator.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py index 864b03e9a7c10..d9e009ed1f10d 100644 --- a/homeassistant/components/ohme/coordinator.py +++ b/homeassistant/components/ohme/coordinator.py @@ -10,7 +10,7 @@ from ohme import ApiException, OhmeApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -83,6 +83,21 @@ class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator): coordinator_name = "Advanced Settings" + def __init__( + self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient + ) -> None: + """Initialise coordinator.""" + super().__init__(hass, config_entry, client) + + @callback + def _dummy_listener() -> None: + pass + + # This coordinator is used by the API library to determine whether the + # charger is online and available. It is therefore required even if no + # entities are using it. + self.async_add_listener(_dummy_listener) + async def _internal_update_data(self) -> None: """Fetch data from API endpoint.""" await self.client.async_get_advanced_settings() From 7037ce989c847c655aad9910a158b05932dbd210 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Fri, 5 Sep 2025 03:23:57 +0100 Subject: [PATCH 10/90] Bump ohmepy version to 1.5.2 (#151707) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 786c615d68a3c..14612fff6eb89 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["ohme==1.5.1"] + "requirements": ["ohme==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 395da3579d681..d34c85433a485 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1580,7 +1580,7 @@ odp-amsterdam==6.1.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.5.1 +ohme==1.5.2 # homeassistant.components.ollama ollama==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf3f71baa7d7b..bc67e29c2aa32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1348,7 +1348,7 @@ objgraph==3.5.0 odp-amsterdam==6.1.2 # homeassistant.components.ohme -ohme==1.5.1 +ohme==1.5.2 # homeassistant.components.ollama ollama==0.5.1 From 2bb4573357189a479dba36a4a230884cefbdbcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 5 Sep 2025 04:23:08 +0200 Subject: [PATCH 11/90] Update Mill library 0.13.1 (#151712) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index c5cc94ead3055..4ae2ac8bbbfb6 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.12.5", "mill-local==0.3.0"] + "requirements": ["millheater==0.13.1", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d34c85433a485..2addc42cd787d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1441,7 +1441,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.5 +millheater==0.13.1 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc67e29c2aa32..9b2562e5727ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1233,7 +1233,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.5 +millheater==0.13.1 # homeassistant.components.minio minio==7.1.12 From 89c335919ac3d411482d9c47c4057f4fc1ff9135 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 5 Sep 2025 03:28:37 -0500 Subject: [PATCH 12/90] Handle match failures in intent HTTP API (#151726) --- homeassistant/components/intent/__init__.py | 2 +- tests/components/intent/test_init.py | 26 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 72853276ab3aa..17ec8602d9834 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -615,7 +615,7 @@ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response intent_result = await intent.async_handle( hass, DOMAIN, intent_name, slots, "", self.context(request) ) - except intent.IntentHandleError as err: + except (intent.IntentHandleError, intent.MatchFailedError) as err: intent_result = intent.IntentResponse(language=language) intent_result.async_set_speech(str(err)) diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 3779930e360a8..1993ebe46e40b 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -73,6 +73,32 @@ async def async_handle(self, intent_obj): } +async def test_http_handle_intent_match_failure( + hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser +) -> None: + """Test handle intent match failure via HTTP API.""" + + assert await async_setup_component(hass, "intent", {}) + + hass.states.async_set( + "cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"} + ) + hass.states.async_set( + "cover.garage_door_2", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"} + ) + async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + + client = await hass_client() + resp = await client.post( + "/api/intent/handle", + json={"name": "HassTurnOn", "data": {"name": "Garage Door"}}, + ) + assert resp.status == 200 + data = await resp.json() + + assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"] + + async def test_cover_intents_loading(hass: HomeAssistant) -> None: """Test Cover Intents Loading.""" assert await async_setup_component(hass, "intent", {}) From dff3d5f8affd4fd5598a096410c450278cae577e Mon Sep 17 00:00:00 2001 From: David Knowles Date: Thu, 4 Sep 2025 22:21:09 -0400 Subject: [PATCH 13/90] Bump pyschlage to 2025.9.0 (#151731) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index b71afe01e5609..eadf5585f3074 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.7.3"] + "requirements": ["pyschlage==2025.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2addc42cd787d..16b1504d7688a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2312,7 +2312,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.7.3 +pyschlage==2025.9.0 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b2562e5727ac..e5394b0c07473 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1924,7 +1924,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.7.3 +pyschlage==2025.9.0 # homeassistant.components.sensibo pysensibo==1.2.1 From 7dbeaa475d45221ac66fadbb4d65d41d67336001 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:37:51 +0200 Subject: [PATCH 14/90] Bump bimmer_connected to 0.17.3 (#151756) --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 81928a59a52bc..327b47bbea29f 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.17.2"] + "requirements": ["bimmer-connected[china]==0.17.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16b1504d7688a..88442d3864a47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -618,7 +618,7 @@ beautifulsoup4==4.13.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.17.2 +bimmer-connected[china]==0.17.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5394b0c07473..046d9b3bfcb0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -555,7 +555,7 @@ base36==0.1.1 beautifulsoup4==4.13.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.17.2 +bimmer-connected[china]==0.17.3 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index b87da22a332ee..06e90c878af21 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -138,6 +138,7 @@ 'state': 'LOW', }), ]), + 'urgent_check_control_messages': None, }), 'climate': dict({ 'activity': 'INACTIVE', @@ -193,6 +194,24 @@ 'state': 'OK', }), ]), + 'next_service_by_distance': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + 'next_service_by_time': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), }), 'data': dict({ 'attributes': dict({ @@ -1053,6 +1072,7 @@ 'state': 'LOW', }), ]), + 'urgent_check_control_messages': None, }), 'climate': dict({ 'activity': 'HEATING', @@ -1108,6 +1128,24 @@ 'state': 'OK', }), ]), + 'next_service_by_distance': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + 'next_service_by_time': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), }), 'data': dict({ 'attributes': dict({ @@ -1858,6 +1896,7 @@ 'state': 'LOW', }), ]), + 'urgent_check_control_messages': None, }), 'climate': dict({ 'activity': 'INACTIVE', @@ -1922,6 +1961,24 @@ 'state': 'OK', }), ]), + 'next_service_by_distance': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + 'next_service_by_time': dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), }), 'data': dict({ 'attributes': dict({ @@ -2621,6 +2678,7 @@ 'has_check_control_messages': False, 'messages': list([ ]), + 'urgent_check_control_messages': None, }), 'climate': dict({ 'activity': 'UNKNOWN', @@ -2658,6 +2716,16 @@ 'state': 'OK', }), ]), + 'next_service_by_distance': None, + 'next_service_by_time': dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), }), 'data': dict({ 'attributes': dict({ @@ -4991,6 +5059,7 @@ 'has_check_control_messages': False, 'messages': list([ ]), + 'urgent_check_control_messages': None, }), 'climate': dict({ 'activity': 'UNKNOWN', @@ -5028,6 +5097,16 @@ 'state': 'OK', }), ]), + 'next_service_by_distance': None, + 'next_service_by_time': dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), }), 'data': dict({ 'attributes': dict({ From 625f586945dabd43a41e1535e69bdaa39a039653 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:42:56 +0200 Subject: [PATCH 15/90] Fix recognition of entity names in default agent with interpunction (#151759) --- .../components/conversation/default_agent.py | 10 ++++---- .../conversation/test_default_agent.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 4b056ead2c29b..938889955e960 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -35,7 +35,7 @@ ) from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.trie import Trie -from hassil.util import merge_dict +from hassil.util import merge_dict, remove_punctuation from home_assistant_intents import ( ErrorKey, FuzzyConfig, @@ -327,12 +327,10 @@ async def async_recognize_intent( if self._exposed_names_trie is not None: # Filter by input string - text_lower = user_input.text.strip().lower() + text = remove_punctuation(user_input.text).strip().lower() slot_lists["name"] = TextSlotList( name="name", - values=[ - result[2] for result in self._exposed_names_trie.find(text_lower) - ], + values=[result[2] for result in self._exposed_names_trie.find(text)], ) start = time.monotonic() @@ -1263,7 +1261,7 @@ async def _make_slot_lists(self) -> dict[str, SlotList]: name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False) for name_value in name_list.values: assert isinstance(name_value.text_in, TextChunk) - name_text = name_value.text_in.text.strip().lower() + name_text = remove_punctuation(name_value.text_in.text).strip().lower() self._exposed_names_trie.insert(name_text, name_value) self._slot_lists = { diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 7c5e897d86c67..a90cd1b55c188 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -231,6 +231,29 @@ async def test_conversation_agent(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("init_components") +async def test_punctuation(hass: HomeAssistant) -> None: + """Test punctuation is handled properly.""" + hass.states.async_set( + "light.test_light", + "off", + attributes={ATTR_FRIENDLY_NAME: "Test light"}, + ) + expose_entity(hass, "light.test_light", True) + + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "Turn?? on,, test;; light!!!", None, Context(), None + ) + + assert len(calls) == 1 + assert calls[0].data["entity_id"][0] == "light.test_light" + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["name"]["value"] == "test light" + assert result.response.intent.slots["name"]["text"] == "test light" + + async def test_expose_flag_automatically_set( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 06480bfd9d7b0978948cb4c6c342a1feed5d68d3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 5 Sep 2025 12:31:33 +0200 Subject: [PATCH 16/90] Fix enable/disable entity in modbus (#151626) --- homeassistant/components/modbus/entity.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 38622c4c19744..8667bc17a796a 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -62,7 +62,6 @@ CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, - SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, ) @@ -143,7 +142,6 @@ def async_disable(self) -> None: self._cancel_call() self._cancel_call = None self._attr_available = False - self.async_write_ha_state() async def async_await_connection(self, _now: Any) -> None: """Wait for first connect.""" @@ -162,11 +160,6 @@ async def async_base_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_disable) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_START_ENTITY, self.async_local_update - ) - ) class BaseStructPlatform(BasePlatform, RestoreEntity): From ae58e633f0775152909a67564f4fcf7d76c75dee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 5 Sep 2025 10:33:36 +0000 Subject: [PATCH 17/90] Bump version to 2025.9.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d46b4cd7717f5..318594196e27a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 45751ec957d31..ee06c96403bb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.9.0" +version = "2025.9.1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 9a43f2776dc350b6b56d9d848f965957e3ce106b Mon Sep 17 00:00:00 2001 From: Bob Igo Date: Tue, 9 Sep 2025 09:18:48 -0400 Subject: [PATCH 18/90] Fix XMPP not working with non-TLS servers (#150957) --- homeassistant/components/xmpp/notify.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index c9829746d59a0..ee57abd769d54 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -146,6 +146,8 @@ def __init__(self): self.enable_starttls = use_tls self.enable_direct_tls = use_tls + self.enable_plaintext = not use_tls + self["feature_mechanisms"].unencrypted_scram = not use_tls self.use_ipv6 = False self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) From ca79f4c963a8f8f53616a46be9c897d0213481ae Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Fri, 5 Sep 2025 07:14:28 -0400 Subject: [PATCH 19/90] Update SharkIQ authentication method (#151046) --- homeassistant/components/sharkiq/__init__.py | 12 +++++++++--- homeassistant/components/sharkiq/config_flow.py | 10 +++++++--- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sharkiq/test_config_flow.py | 16 +++++++++++++--- tests/components/sharkiq/test_vacuum.py | 3 +++ 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index e560bb77b5788..b87f52ba7b11c 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -3,6 +3,7 @@ import asyncio from contextlib import suppress +import aiohttp from sharkiq import ( AylaApi, SharkIqAuthError, @@ -15,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( API_TIMEOUT, @@ -56,10 +57,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b data={**config_entry.data, CONF_REGION: SHARKIQ_REGION_DEFAULT}, ) + new_websession = async_create_clientsession( + hass, + cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False), + ) + ayla_api = get_ayla_api( username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), + websession=new_websession, europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) @@ -94,7 +100,7 @@ async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator): await coordinator.ayla_api.async_sign_out() -async def async_update_options(hass, config_entry): +async def async_update_options(hass: HomeAssistant, config_entry): """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 87367fcf093ea..7174c6347879a 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( DOMAIN, @@ -44,15 +44,19 @@ async def _validate_input( hass: HomeAssistant, data: Mapping[str, Any] ) -> dict[str, str]: """Validate the user input allows us to connect.""" + new_websession = async_create_clientsession( + hass, + cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False), + ) ayla_api = get_ayla_api( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], - websession=async_get_clientsession(hass), + websession=new_websession, europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: - async with asyncio.timeout(10): + async with asyncio.timeout(15): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except (TimeoutError, aiohttp.ClientError, TypeError) as error: diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index c29fc582462c4..793f65483ea2b 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.1.1"] + "requirements": ["sharkiq==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 88442d3864a47..c6b1483fcda28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.1 +sharkiq==1.4.0 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 046d9b3bfcb0a..43803a1861be3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2290,7 +2290,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.1 +sharkiq==1.4.0 # homeassistant.components.simplefin simplefin4py==0.0.18 diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index 22a77678c0d70..f96b2f31e0b16 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -47,6 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch("sharkiq.AylaApi.async_sign_in", return_value=True), + patch("sharkiq.AylaApi.async_set_cookie"), patch( "homeassistant.components.sharkiq.async_setup_entry", return_value=True, @@ -84,7 +85,10 @@ async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str) DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch.object(AylaApi, "async_sign_in", side_effect=exc): + with ( + patch.object(AylaApi, "async_sign_in", side_effect=exc), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, @@ -101,7 +105,10 @@ async def test_reauth_success(hass: HomeAssistant) -> None: result = await mock_config.start_reauth_flow(hass) - with patch("sharkiq.AylaApi.async_sign_in", return_value=True): + with ( + patch("sharkiq.AylaApi.async_sign_in", return_value=True), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) @@ -132,7 +139,10 @@ async def test_reauth( result = await mock_config.start_reauth_flow(hass) - with patch("sharkiq.AylaApi.async_sign_in", side_effect=side_effect): + with ( + patch("sharkiq.AylaApi.async_sign_in", side_effect=side_effect), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index bfb2176026b73..5b5339ec7a213 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -80,6 +80,9 @@ class MockAyla(AylaApi): async def async_sign_in(self): """Instead of signing in, just return.""" + async def async_set_cookie(self): + """Instead of getting cookies, just return.""" + async def async_refresh_auth(self): """Instead of refreshing auth, just return.""" From 0b56ec16edc5b8828f1f2c01af17fc495ef5b03b Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:52:29 +0200 Subject: [PATCH 20/90] Add event entity on websocket ready in Husqvarna Automower (#151428) --- .../components/husqvarna_automower/event.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py index 7fe8bae8c2de6..2d7edcf1c73f3 100644 --- a/homeassistant/components/husqvarna_automower/event.py +++ b/homeassistant/components/husqvarna_automower/event.py @@ -36,12 +36,13 @@ async def async_setup_entry( """Set up Automower message event entities. Entities are created dynamically based on messages received from the API, - but only for mowers that support message events. + but only for mowers that support message events after the WebSocket connection + is ready. """ coordinator = config_entry.runtime_data entity_registry = er.async_get(hass) - restored_mowers = { + restored_mowers: set[str] = { entry.unique_id.removesuffix("_message") for entry in er.async_entries_for_config_entry( entity_registry, config_entry.entry_id @@ -49,14 +50,20 @@ async def async_setup_entry( if entry.domain == EVENT_DOMAIN } - async_add_entities( - AutomowerMessageEventEntity(mower_id, coordinator) - for mower_id in restored_mowers - if mower_id in coordinator.data - ) + @callback + def _on_ws_ready() -> None: + async_add_entities( + AutomowerMessageEventEntity(mower_id, coordinator, websocket_alive=True) + for mower_id in restored_mowers + if mower_id in coordinator.data + ) + coordinator.api.unregister_ws_ready_callback(_on_ws_ready) + + coordinator.api.register_ws_ready_callback(_on_ws_ready) @callback def _handle_message(msg: SingleMessageData) -> None: + """Add entity dynamically if a new mower sends messages.""" if msg.id in restored_mowers: return @@ -78,11 +85,17 @@ def __init__( self, mower_id: str, coordinator: AutomowerDataUpdateCoordinator, + *, + websocket_alive: bool | None = None, ) -> None: """Initialize Automower message event entity.""" super().__init__(mower_id, coordinator) self._attr_unique_id = f"{mower_id}_message" - self.websocket_alive: bool = coordinator.websocket_alive + self.websocket_alive: bool = ( + websocket_alive + if websocket_alive is not None + else coordinator.websocket_alive + ) @property def available(self) -> bool: From fe01e9601240955ec4036599a37cc22d5f0d80f1 Mon Sep 17 00:00:00 2001 From: hbludworth <63749412+hbludworth@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:16:37 -0600 Subject: [PATCH 21/90] Fix Aladdin Connect state not updating (#151652) Co-authored-by: Joostlek --- .../components/aladdin_connect/__init__.py | 18 +----------------- .../components/aladdin_connect/coordinator.py | 6 ++++++ .../components/aladdin_connect/cover.py | 4 +++- .../components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aladdin_connect/test_init.py | 2 ++ 7 files changed, 15 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index adcc53bfc75c8..48bedafdd1ab8 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from genie_partner_sdk.client import AladdinConnectClient -from genie_partner_sdk.model import GarageDoor from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -36,22 +35,7 @@ async def async_setup_entry( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - sdk_doors = await client.get_doors() - - # Convert SDK GarageDoor objects to integration GarageDoor objects - doors = [ - GarageDoor( - { - "device_id": door.device_id, - "door_number": door.door_number, - "name": door.name, - "status": door.status, - "link_status": door.link_status, - "battery_level": door.battery_level, - } - ) - for door in sdk_doors - ] + doors = await client.get_doors() entry.runtime_data = { door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py index 74afbe8fca972..718aed8e44572 100644 --- a/homeassistant/components/aladdin_connect/coordinator.py +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -41,4 +41,10 @@ def __init__( async def _async_update_data(self) -> GarageDoor: """Fetch data from the Aladdin Connect API.""" await self.client.update_door(self.data.device_id, self.data.door_number) + self.data.status = self.client.get_door_status( + self.data.device_id, self.data.door_number + ) + self.data.battery_level = self.client.get_battery_status( + self.data.device_id, self.data.door_number + ) return self.data diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 7af0e4eb2cee2..4bc787539fd9d 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -49,7 +49,9 @@ async def async_close_cover(self, **kwargs: Any) -> None: @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - return self.coordinator.data.status == "closed" + if (status := self.coordinator.data.status) is None: + return None + return status == "closed" @property def is_closing(self) -> bool | None: diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 67c755e29a8ec..3b192d17b9871 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["genie-partner-sdk==1.0.10"] + "requirements": ["genie-partner-sdk==1.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index c6b1483fcda28..9beabcb8d8c4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1002,7 +1002,7 @@ gassist-text==0.0.14 gcal-sync==8.0.0 # homeassistant.components.aladdin_connect -genie-partner-sdk==1.0.10 +genie-partner-sdk==1.0.11 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43803a1861be3..8b3a6caa97527 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -872,7 +872,7 @@ gassist-text==0.0.14 gcal-sync==8.0.0 # homeassistant.components.aladdin_connect -genie-partner-sdk==1.0.10 +genie-partner-sdk==1.0.11 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index e26e5234f1c27..bc147839c2fed 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -30,6 +30,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: mock_door.status = "closed" mock_door.link_status = "connected" mock_door.battery_level = 100 + mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" mock_client = AsyncMock() mock_client.get_doors.return_value = [mock_door] @@ -80,6 +81,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: mock_door.status = "closed" mock_door.link_status = "connected" mock_door.battery_level = 100 + mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" # Mock client mock_client = AsyncMock() From 36b3133fa2afeaf495a9c7c068a65bc21bfe88bd Mon Sep 17 00:00:00 2001 From: blotus Date: Fri, 5 Sep 2025 14:40:14 +0200 Subject: [PATCH 22/90] Fix support for Ecowitt soil moisture sensors (#151685) --- homeassistant/components/ecowitt/sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index ccaaeaae3de39..6620f61961fff 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -218,6 +218,12 @@ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + EcoWittSensorTypes.SOIL_MOISTURE: SensorEntityDescription( + key="SOIL_MOISTURE", + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), } From b387acffb76744d6ad5c8011f573cc946e0fffaa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 Sep 2025 12:48:23 +0200 Subject: [PATCH 23/90] Fix update of the entity ID does not clean up an old restored state (#151696) Co-authored-by: Erik Montnemery --- homeassistant/helpers/entity_registry.py | 16 +++++++- tests/helpers/test_entity_registry.py | 51 +++++++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index f1a765b3ddcc8..ae3da68bd74bc 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1898,11 +1898,25 @@ def _async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) - @callback def cleanup_restored_states_filter(event_data: Mapping[str, Any]) -> bool: """Clean up restored states filter.""" - return bool(event_data["action"] == "remove") + return (event_data["action"] == "remove") or ( + event_data["action"] == "update" + and "old_entity_id" in event_data + and event_data["entity_id"] != event_data["old_entity_id"] + ) @callback def cleanup_restored_states(event: Event[EventEntityRegistryUpdatedData]) -> None: """Clean up restored states.""" + if event.data["action"] == "update": + old_entity_id = event.data["old_entity_id"] + old_state = hass.states.get(old_entity_id) + if old_state is None or not old_state.attributes.get(ATTR_RESTORED): + return + hass.states.async_remove(old_entity_id, context=event.context) + if entry := registry.async_get(event.data["entity_id"]): + entry.write_unavailable_state(hass) + return + state = hass.states.get(event.data["entity_id"]) if state is None or not state.attributes.get(ATTR_RESTORED): diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 421f52bca7376..593e1ea97032a 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1462,9 +1462,56 @@ async def test_update_entity_unique_id_conflict( ) -async def test_update_entity_entity_id(entity_registry: er.EntityRegistry) -> None: - """Test entity's entity_id is updated.""" +async def test_update_entity_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity's entity_id is updated for entity with a restored state.""" + hass.set_state(CoreState.not_running) + + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + "light", "hue", "5678", config_entry=mock_config + ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) + await hass.async_block_till_done() + assert ( + entity_registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + ) + state = hass.states.get(entry.entity_id) + assert state is not None + assert state.state == "unavailable" + assert state.attributes == {"restored": True, "supported_features": 0} + + new_entity_id = "light.blah" + assert new_entity_id != entry.entity_id + with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: + updated_entry = entity_registry.async_update_entity( + entry.entity_id, new_entity_id=new_entity_id + ) + assert updated_entry != entry + assert updated_entry.entity_id == new_entity_id + assert mock_schedule_save.call_count == 1 + + assert entity_registry.async_get(entry.entity_id) is None + assert entity_registry.async_get(new_entity_id) is not None + + # The restored state should be removed + old_state = hass.states.get(entry.entity_id) + assert old_state is None + + # The new entity should have an unavailable initial state + new_state = hass.states.get(new_entity_id) + assert new_state is not None + assert new_state.state == "unavailable" + + +async def test_update_entity_entity_id_without_state( + entity_registry: er.EntityRegistry, +) -> None: + """Test entity's entity_id is updated for entity without a state.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") + assert ( entity_registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id ) From 0091dafcb04b8fc0bba0a2aa32a7386dc88a90ec Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Fri, 5 Sep 2025 18:47:29 +0300 Subject: [PATCH 24/90] Revert "Jewish Calendar add coordinator " (#151780) --- .../components/jewish_calendar/__init__.py | 18 +-- .../jewish_calendar/binary_sensor.py | 3 +- .../components/jewish_calendar/coordinator.py | 116 -------------- .../components/jewish_calendar/diagnostics.py | 2 +- .../components/jewish_calendar/entity.py | 64 +++++++- .../components/jewish_calendar/sensor.py | 36 +++-- .../snapshots/test_diagnostics.ambr | 150 +++++++++--------- 7 files changed, 163 insertions(+), 226 deletions(-) delete mode 100644 homeassistant/components/jewish_calendar/coordinator.py diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 0f5a066600c81..8e01b6b6ae0b1 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -29,8 +29,7 @@ DEFAULT_LANGUAGE, DOMAIN, ) -from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator -from .entity import JewishCalendarConfigEntry +from .entity import JewishCalendarConfigEntry, JewishCalendarData from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -70,7 +69,7 @@ async def async_setup_entry( ) ) - data = JewishCalendarData( + config_entry.runtime_data = JewishCalendarData( language, diaspora, location, @@ -78,11 +77,8 @@ async def async_setup_entry( havdalah_offset, ) - coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data) - await coordinator.async_config_entry_first_refresh() - - config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True @@ -90,13 +86,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - coordinator = config_entry.runtime_data - if coordinator.event_unsub: - coordinator.event_unsub() - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) async def async_migrate_entry( diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 205691bc1838c..d5097df962f5c 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -72,7 +72,8 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now()) + zmanim = self.make_zmanim(dt.date.today()) + return self.entity_description.is_on(zmanim)(dt_util.now()) def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: """Return a list of times to update the sensor.""" diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py deleted file mode 100644 index 21713313043e7..0000000000000 --- a/homeassistant/components/jewish_calendar/coordinator.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Data update coordinator for Jewish calendar.""" - -from dataclasses import dataclass -import datetime as dt -import logging - -from hdate import HDateInfo, Location, Zmanim -from hdate.translator import Language, set_language - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator] - - -@dataclass -class JewishCalendarData: - """Jewish Calendar runtime dataclass.""" - - language: Language - diaspora: bool - location: Location - candle_lighting_offset: int - havdalah_offset: int - dateinfo: HDateInfo | None = None - zmanim: Zmanim | None = None - - -class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]): - """Data update coordinator class for Jewish calendar.""" - - config_entry: JewishCalendarConfigEntry - event_unsub: CALLBACK_TYPE | None = None - - def __init__( - self, - hass: HomeAssistant, - config_entry: JewishCalendarConfigEntry, - data: JewishCalendarData, - ) -> None: - """Initialize the coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry) - self.data = data - self._unsub_update: CALLBACK_TYPE | None = None - set_language(data.language) - - async def _async_update_data(self) -> JewishCalendarData: - """Return HDate and Zmanim for today.""" - now = dt_util.now() - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - - self.data.dateinfo = HDateInfo(today, self.data.diaspora) - self.data.zmanim = self.make_zmanim(today) - self.async_schedule_future_update() - return self.data - - @callback - def async_schedule_future_update(self) -> None: - """Schedule the next update of the sensor for the upcoming midnight.""" - # Cancel any existing update - if self._unsub_update: - self._unsub_update() - self._unsub_update = None - - # Calculate the next midnight - next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) - - _LOGGER.debug("Scheduling next update at %s", next_midnight) - - # Schedule update at next midnight - self._unsub_update = event.async_track_point_in_time( - self.hass, self._handle_midnight_update, next_midnight - ) - - @callback - def _handle_midnight_update(self, _now: dt.datetime) -> None: - """Handle midnight update callback.""" - self._unsub_update = None - self.async_set_updated_data(self.data) - - async def async_shutdown(self) -> None: - """Cancel any scheduled updates when the coordinator is shutting down.""" - await super().async_shutdown() - if self._unsub_update: - self._unsub_update() - self._unsub_update = None - - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self.data.location, - candle_lighting_offset=self.data.candle_lighting_offset, - havdalah_offset=self.data.havdalah_offset, - ) - - @property - def zmanim(self) -> Zmanim: - """Return the current Zmanim.""" - assert self.data.zmanim is not None, "Zmanim data not available" - return self.data.zmanim - - @property - def dateinfo(self) -> HDateInfo: - """Return the current HDateInfo.""" - assert self.data.dateinfo is not None, "HDateInfo data not available" - return self.data.dateinfo diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py index f2db0786b1282..27415282b6df5 100644 --- a/homeassistant/components/jewish_calendar/diagnostics.py +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index d3007212739bd..d5e4112907575 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,22 +1,48 @@ """Entity representing a Jewish Calendar sensor.""" from abc import abstractmethod +from dataclasses import dataclass import datetime as dt +import logging -from hdate import Zmanim +from hdate import HDateInfo, Location, Zmanim +from hdate.translator import Language, set_language +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator +_LOGGER = logging.getLogger(__name__) -class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] + + +@dataclass +class JewishCalendarDataResults: + """Jewish Calendar results dataclass.""" + + dateinfo: HDateInfo + zmanim: Zmanim + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: Language + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int + results: JewishCalendarDataResults | None = None + + +class JewishCalendarEntity(Entity): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True @@ -29,13 +55,23 @@ def __init__( description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" - super().__init__(config_entry.runtime_data) self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) + self.data = config_entry.runtime_data + set_language(self.data.language) + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -56,9 +92,10 @@ def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: def _schedule_update(self) -> None: """Schedule the next update of the sensor.""" now = dt_util.now() + zmanim = self.make_zmanim(now.date()) update = dt_util.start_of_local_day() + dt.timedelta(days=1) - for update_time in self._update_times(self.coordinator.zmanim): + for update_time in self._update_times(zmanim): if update_time is not None and now < update_time < update: update = update_time @@ -73,4 +110,17 @@ def _update(self, now: dt.datetime | None = None) -> None: """Update the sensor data.""" self._update_unsub = None self._schedule_update() + self.create_results(now) self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 579c8e0f6a686..d9ad89237f53c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @@ -236,18 +236,25 @@ def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: return [] return [self.entity_description.next_update_fn(zmanim)] - def get_dateinfo(self) -> HDateInfo: + def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: """Get the next date info.""" - now = dt_util.now() - update = None + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" + + if now is None: + now = dt_util.now() + today = now.date() + zmanim = self.make_zmanim(today) + update = None if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(self.coordinator.zmanim) + update = self.entity_description.next_update_fn(zmanim) - _LOGGER.debug("Today: %s, update: %s", now.date(), update) + _LOGGER.debug("Today: %s, update: %s", today, update) if update is not None and now >= update: - return self.coordinator.dateinfo.next_day - return self.coordinator.dateinfo + return self.data.results.dateinfo.next_day + return self.data.results.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -264,9 +271,7 @@ def __init__( super().__init__(config_entry, description) # Set the options for enumeration sensors if self.entity_description.options_fn is not None: - self._attr_options = self.entity_description.options_fn( - self.coordinator.data.diaspora - ) + self._attr_options = self.entity_description.options_fn(self.data.diaspora) @property def native_value(self) -> str | int | dt.datetime | None: @@ -290,8 +295,9 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @property def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: - return self.coordinator.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn( - self.get_dateinfo(), self.coordinator.make_zmanim - ) + return self.data.results.zmanim.zmanim[self.entity_description.key].local + return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 859cdefd9c2af..0a392e101c515 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,15 +3,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -26,22 +17,33 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + }), }), }), }), @@ -57,15 +59,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -80,22 +73,33 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + }), }), }), }), @@ -111,15 +115,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -134,22 +129,33 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + }), }), }), }), From 9dafc0e02f4925e468b57168dea5990707e55a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 10 Sep 2025 15:34:32 +0200 Subject: [PATCH 25/90] Remove device class for Matter NitrogenDioxideSensor (#151782) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/matter/sensor.py | 2 +- homeassistant/components/matter/strings.json | 3 +++ tests/components/matter/snapshots/test_sensor.ambr | 10 ++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index d8e55b7b1ff9d..303a8e94df41e 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -634,8 +634,8 @@ def _update_from_device(self) -> None: platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="NitrogenDioxideSensor", + translation_key="nitrogen_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - device_class=SensorDeviceClass.NITROGEN_DIOXIDE, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 9a0bb77adfa7a..f36087a0f9981 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -435,6 +435,9 @@ "evse_soc": { "name": "State of charge" }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, "pump_control_mode": { "name": "Control mode", "state": { diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 290016f0ff3e6..143ee0a32451a 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -353,14 +353,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nitrogen_dioxide', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -368,7 +368,6 @@ # name: test_sensors[air_purifier][sensor.air_purifier_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'nitrogen_dioxide', 'friendly_name': 'Air Purifier Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', @@ -955,14 +954,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nitrogen_dioxide', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -970,7 +969,6 @@ # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'nitrogen_dioxide', 'friendly_name': 'lightfi-aq1-air-quality-sensor Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', From 1b27acdde00764f96878a95bab7b71cd6e77cc20 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 9 Sep 2025 21:08:04 +0200 Subject: [PATCH 26/90] Improve config entry migration for edge cases in Alexa Devices (#151788) --- .../components/alexa_devices/__init__.py | 24 +++++- .../components/alexa_devices/config_flow.py | 2 +- .../components/alexa_devices/const.py | 1 + tests/components/alexa_devices/conftest.py | 13 +++- .../snapshots/test_diagnostics.ambr | 3 +- tests/components/alexa_devices/test_init.py | 74 +++++++++++++++++-- 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 9407a2d8987fd..af0a3d7818cc3 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -5,7 +5,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import _LOGGER, CONF_LOGIN_DATA, COUNTRY_DOMAINS, DOMAIN +from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator from .services import async_setup_services @@ -42,7 +42,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Migrate old entry.""" - if entry.version == 1 and entry.minor_version == 1: + + if entry.version == 1 and entry.minor_version < 3: + if CONF_SITE in entry.data: + # Site in data (wrong place), just move to login data + new_data = entry.data.copy() + new_data[CONF_LOGIN_DATA][CONF_SITE] = new_data[CONF_SITE] + new_data.pop(CONF_SITE) + hass.config_entries.async_update_entry( + entry, data=new_data, version=1, minor_version=3 + ) + return True + + if CONF_SITE in entry.data[CONF_LOGIN_DATA]: + # Site is there, just update version to avoid future migrations + hass.config_entries.async_update_entry(entry, version=1, minor_version=3) + return True + _LOGGER.debug( "Migrating from version %s.%s", entry.version, entry.minor_version ) @@ -53,10 +69,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> # Add site to login data new_data = entry.data.copy() - new_data[CONF_LOGIN_DATA]["site"] = f"https://www.amazon.{domain}" + new_data[CONF_LOGIN_DATA][CONF_SITE] = f"https://www.amazon.{domain}" hass.config_entries.async_update_entry( - entry, data=new_data, version=1, minor_version=2 + entry, data=new_data, version=1, minor_version=3 ) _LOGGER.info( diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index ccf18fd45585f..f266a8688547e 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -52,7 +52,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Alexa Devices.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/alexa_devices/const.py b/homeassistant/components/alexa_devices/const.py index c60096bae5713..e783f67f50341 100644 --- a/homeassistant/components/alexa_devices/const.py +++ b/homeassistant/components/alexa_devices/const.py @@ -6,6 +6,7 @@ DOMAIN = "alexa_devices" CONF_LOGIN_DATA = "login_data" +CONF_SITE = "site" DEFAULT_DOMAIN = "com" COUNTRY_DOMAINS = { diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 236f7b23dc48e..2ef2c2431dc13 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -7,7 +7,11 @@ from aioamazondevices.const import DEVICE_TYPE_TO_MODEL import pytest -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME @@ -81,9 +85,12 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_LOGIN_DATA: {"session": "test-session"}, + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, }, unique_id=TEST_USERNAME, version=1, - minor_version=2, + minor_version=3, ) diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 6f9dc9a5cc3e0..9ae5832ce3343 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -49,6 +49,7 @@ 'data': dict({ 'login_data': dict({ 'session': 'test-session', + 'site': 'https://www.amazon.com', }), 'password': '**REDACTED**', 'username': '**REDACTED**', @@ -57,7 +58,7 @@ 'discovery_keys': dict({ }), 'domain': 'alexa_devices', - 'minor_version': 2, + 'minor_version': 3, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 6c3faffd27b83..328654682e911 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -2,9 +2,14 @@ from unittest.mock import AsyncMock +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -32,24 +37,81 @@ async def test_device_info( assert device_entry == snapshot +@pytest.mark.parametrize( + ("minor_version", "extra_data"), + [ + # Standard migration case + ( + 1, + { + CONF_COUNTRY: "US", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + # Edge case #1: no country, site already in login data, minor version 1 + ( + 1, + { + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, + }, + ), + # Edge case #2: no country, site in data (wrong place), minor version 1 + ( + 1, + { + CONF_SITE: "https://www.amazon.com", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + # Edge case #3: no country, site already in login data, minor version 2 + ( + 2, + { + CONF_LOGIN_DATA: { + "session": "test-session", + CONF_SITE: "https://www.amazon.com", + }, + }, + ), + # Edge case #4: no country, site in data (wrong place), minor version 2 + ( + 2, + { + CONF_SITE: "https://www.amazon.com", + CONF_LOGIN_DATA: { + "session": "test-session", + }, + }, + ), + ], +) async def test_migrate_entry( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, + minor_version: int, + extra_data: dict[str, str], ) -> None: """Test successful migration of entry data.""" + config_entry = MockConfigEntry( domain=DOMAIN, title="Amazon Test Account", data={ - CONF_COUNTRY: "US", # country should be in COUNTRY_DOMAINS exceptions CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_LOGIN_DATA: {"session": "test-session"}, + **(extra_data), }, unique_id=TEST_USERNAME, version=1, - minor_version=1, + minor_version=minor_version, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -57,5 +119,5 @@ async def test_migrate_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.minor_version == 2 - assert config_entry.data[CONF_LOGIN_DATA]["site"] == "https://www.amazon.com" + assert config_entry.minor_version == 3 + assert config_entry.data[CONF_LOGIN_DATA][CONF_SITE] == "https://www.amazon.com" From 0c093646c9032765f79fca5d143d03759cceb5c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 05:57:44 -0500 Subject: [PATCH 27/90] Bump habluetooth to 5.3.1 (#151803) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5559e5e871030..b4d188550d36b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.3.0" + "habluetooth==5.3.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b43da76a609fe..c77c07c1551e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.3.0 +habluetooth==5.3.1 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9beabcb8d8c4b..e8af668240056 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.3.0 +habluetooth==5.3.1 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b3a6caa97527..cb0f84fa76fb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,7 +995,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.3.0 +habluetooth==5.3.1 # homeassistant.components.cloud hass-nabucasa==1.1.0 From c78bc26b83e0e8a9d40c286e897804a585f8c01c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 6 Sep 2025 13:02:35 +0200 Subject: [PATCH 28/90] Fix KNX BinarySensor config_store data (#151808) --- .../components/knx/storage/config_store.py | 13 ++++++--- .../components/knx/storage/migration.py | 10 +++++++ .../fixtures/config_store_binarysensor.json | 3 +-- .../config_store_binarysensor_v2_1.json | 27 +++++++++++++++++++ .../knx/fixtures/config_store_light.json | 2 +- tests/components/knx/test_config_store.py | 16 +++++++++++ 6 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store_binarysensor_v2_1.json diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2e93256de47dd..55505fa64e5e2 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,11 +13,12 @@ from ..const import DOMAIN from .const import CONF_DATA -from .migration import migrate_1_to_2 +from .migration import migrate_1_to_2, migrate_2_1_to_2_2 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION: Final = 2 +STORAGE_VERSION_MINOR: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -54,9 +55,13 @@ async def _async_migrate_func( ) -> dict[str, Any]: """Migrate to the new version.""" if old_major_version == 1: - # version 2 introduced in 2025.8 + # version 2.1 introduced in 2025.8 migrate_1_to_2(old_data) + if old_major_version <= 2 and old_minor_version < 2: + # version 2.2 introduced in 2025.9.2 + migrate_2_1_to_2_2(old_data) + return old_data @@ -71,7 +76,9 @@ def __init__( """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py index f7d7941e5cc10..fbce1cc7618f9 100644 --- a/homeassistant/components/knx/storage/migration.py +++ b/homeassistant/components/knx/storage/migration.py @@ -4,6 +4,7 @@ from homeassistant.const import Platform +from ..const import CONF_RESPOND_TO_READ from . import const as store_const @@ -40,3 +41,12 @@ def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: if color: light_knx_data[store_const.CONF_COLOR] = color + + +def migrate_2_1_to_2_2(data: dict[str, Any]) -> None: + """Migrate from schema 2.1 to schema 2.2.""" + if b_sensors := data.get("entities", {}).get(Platform.BINARY_SENSOR): + for b_sensor in b_sensors.values(): + # "respond_to_read" was never used for binary_sensor and is not valid + # in the new schema. It was set as default in Store schema v1 and v2.1 + b_sensor["knx"].pop(CONF_RESPOND_TO_READ, None) diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 2b6e5887f9e92..010149df07dd8 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 1, + "minor_version": 2, "key": "knx/config_store.json", "data": { "entities": { @@ -17,7 +17,6 @@ "state": "3/2/21", "passive": [] }, - "respond_to_read": false, "sync_state": true } } diff --git a/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json b/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json new file mode 100644 index 0000000000000..2b6e5887f9e92 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": {}, + "binary_sensor": { + "knx_es_01JJP1XDQRXB0W6YYGXW6Y1X10": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_sensor": { + "state": "3/2/21", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json index 61ec1044746e3..e0e1089ed2d6d 100644 --- a/tests/components/knx/fixtures/config_store_light.json +++ b/tests/components/knx/fixtures/config_store_light.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 1, + "minor_version": 2, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 3e902f8f40275..bb6af6408b8c7 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -458,3 +458,19 @@ async def test_migration_1_to_2( hass, "config_store_light.json", "knx" ) assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data + + +async def test_migration_2_1_to_2_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 2.1 to schema 2.2.""" + await knx.setup_integration( + config_store_fixture="config_store_binarysensor_v2_1.json", + state_updater=False, + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_binarysensor.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data From 64ec4609c5f09b43ca1531d63a411f9161fab8b6 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 12 Sep 2025 10:43:45 +0200 Subject: [PATCH 29/90] Fix KNX Light - individual color initialisation from UI config (#151815) --- homeassistant/components/knx/light.py | 14 +++++-- .../knx/storage/entity_store_schema.py | 10 ++--- .../knx/snapshots/test_websocket.ambr | 38 +++++++++---------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 1ab6883a4371c..bd54e5f75d98c 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_SWITCH ), - group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), + group_address_brightness_green=conf.get_write( + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS + ), group_address_brightness_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), - group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), - group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), - group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), + group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH), + group_address_switch_blue_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_BLUE_SWITCH + ), + group_address_brightness_blue=conf.get_write( + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS + ), group_address_brightness_blue_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index fe0dbf31b6bb7..21252e35f3a7a 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -240,19 +240,19 @@ class LightColorMode(StrEnum): write_required=True, valid_dpt="5.001" ), "section_blue": KNXSectionFlat(), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( - write_required=True, valid_dpt="5.001" - ), vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), - "section_white": KNXSectionFlat(), - vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( write_required=True, valid_dpt="5.001" ), + "section_white": KNXSectionFlat(), vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), }, ), GroupSelectOption( diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index b99196c8769b0..6dc651195aeb5 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -575,7 +575,8 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_blue_brightness', + 'name': 'ga_blue_switch', + 'optional': True, 'options': dict({ 'passive': True, 'state': dict({ @@ -583,20 +584,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), - 'required': True, + 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_blue_switch', - 'optional': True, + 'name': 'ga_blue_brightness', 'options': dict({ 'passive': True, 'state': dict({ @@ -604,15 +604,15 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), - 'required': False, + 'required': True, 'type': 'knx_group_address', }), dict({ @@ -622,7 +622,7 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_white_brightness', + 'name': 'ga_white_switch', 'optional': True, 'options': dict({ 'passive': True, @@ -631,19 +631,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_white_switch', + 'name': 'ga_white_brightness', 'optional': True, 'options': dict({ 'passive': True, @@ -652,12 +652,12 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), 'required': False, From 3af86167648b24f76ffddcf8fc7b3bfc30d95ed4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 7 Sep 2025 13:34:31 +0200 Subject: [PATCH 30/90] Mark Tractive switches as unavailable when tacker is in the enegy saving zone (#151817) --- homeassistant/components/tractive/__init__.py | 1 + homeassistant/components/tractive/switch.py | 3 +- tests/components/tractive/test_switch.py | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 60bae9bfd2e38..f00e0fec41249 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -291,6 +291,7 @@ def _send_switch_update(self, event: dict[str, Any]) -> None: for switch, key in SWITCH_KEY_MAP.items(): if switch_data := event.get(key): payload[switch] = switch_data["active"] + payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING" self._dispatch_tracker_event( TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index da2c8e35ff7c8..e4db6d69bee1b 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -18,6 +18,7 @@ ATTR_BUZZER, ATTR_LED, ATTR_LIVE_TRACKING, + ATTR_POWER_SAVING, TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -104,7 +105,7 @@ def handle_status_update(self, event: dict[str, Any]) -> None: # We received an event, so the service is online and the switch entities should # be available. - self._attr_available = True + self._attr_available = not event[ATTR_POWER_SAVING] self._attr_is_on = event[self.entity_description.key] self.async_write_ha_state() diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index 92e4676aef11a..0b9213bee92b4 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -12,6 +12,7 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -226,3 +227,42 @@ async def test_switch_off_with_exception( state = hass.states.get(entity_id) assert state assert state.state == STATE_ON + + +async def test_switch_unavailable( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch is navailable when the tracker is in the energy saving zone.""" + entity_id = "switch.test_pet_tracker_buzzer" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + event = { + "tracker_id": "device_id_123", + "buzzer_control": {"active": True}, + "led_control": {"active": False}, + "live_tracking": {"active": True}, + "tracker_state_reason": "POWER_SAVING", + } + mock_tractive_client.send_switch_event(mock_config_entry, event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON From c3c65af450049f4d9bcebc0ff9e077bf3d81eda7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 6 Sep 2025 20:21:12 +0200 Subject: [PATCH 31/90] Allow delay > 1 in modbus. (#151832) --- homeassistant/components/modbus/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 8667bc17a796a..37afb437172a5 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -345,7 +345,6 @@ async def async_turn(self, command: int) -> None: return if self._verify_delay: - assert self._verify_delay == 1 if self._cancel_call: self._cancel_call() self._cancel_call = None From b8d9883e74e331a8d02a288539f27251f391deba Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 6 Sep 2025 20:20:49 +0200 Subject: [PATCH 32/90] max_temp / min_temp in modbus light could only be int, otherwise an assert was provoked. (#151833) --- homeassistant/components/modbus/__init__.py | 4 ++-- homeassistant/components/modbus/light.py | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index ab387030af8ec..e3bebd1590455 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -267,8 +267,8 @@ { vol.Required(CONF_TARGET_TEMP): hvac_fixedsize_reglist_validator, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float), - vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(int), + vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(int), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int, diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 7b1035c702bab..b5098cb6c4699 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -64,7 +64,8 @@ def __init__( self._attr_color_mode = self._detect_color_mode(config) self._attr_supported_color_modes = {self._attr_color_mode} - # Set min/max kelvin values if the mode is COLOR_TEMP + self._attr_min_color_temp_kelvin: int = LIGHT_DEFAULT_MIN_KELVIN + self._attr_max_color_temp_kelvin: int = LIGHT_DEFAULT_MAX_KELVIN if self._attr_color_mode == ColorMode.COLOR_TEMP: self._attr_min_color_temp_kelvin = config.get( CONF_MIN_TEMP, LIGHT_DEFAULT_MIN_KELVIN @@ -193,9 +194,6 @@ def _convert_modbus_percent_to_brightness(percent: int) -> int: def _convert_modbus_percent_to_temperature(self, percent: int) -> int: """Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К).""" - assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( - self._attr_max_color_temp_kelvin, int - ) return round( self._attr_min_color_temp_kelvin + ( @@ -216,9 +214,6 @@ def _convert_brightness_to_modbus(brightness: int) -> int: def _convert_color_temp_to_modbus(self, kelvin: int) -> int: """Convert color temperature from Kelvin to the Modbus scale (0-100).""" - assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( - self._attr_max_color_temp_kelvin, int - ) return round( LIGHT_MODBUS_SCALE_MIN + (kelvin - self._attr_min_color_temp_kelvin) From 6d8c35cfe9ecc2802a4bbd1b41606d5d3165dd1c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 6 Sep 2025 20:20:21 +0200 Subject: [PATCH 33/90] removed assert fron entity in modbus. (#151834) --- homeassistant/components/modbus/entity.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 37afb437172a5..2bd81ac2ef88b 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -94,18 +94,10 @@ def __init__( self._attr_name = entry[CONF_NAME] self._attr_device_class = entry.get(CONF_DEVICE_CLASS) - def get_optional_numeric_config(config_name: str) -> int | float | None: - if (val := entry.get(config_name)) is None: - return None - assert isinstance(val, (float, int)), ( - f"Expected float or int but {config_name} was {type(val)}" - ) - return val - - self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) - self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) + self._min_value = entry.get(CONF_MIN_VALUE) + self._max_value = entry.get(CONF_MAX_VALUE) self._nan_value = entry.get(CONF_NAN_VALUE) - self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) + self._zero_suppress = entry.get(CONF_ZERO_SUPPRESS) @abstractmethod async def _async_update(self) -> None: From baff541f46f663913488d21db383abac8ebcbbc6 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 6 Sep 2025 16:07:56 -0400 Subject: [PATCH 34/90] Bump pydrawise to 2025.9.0 (#151842) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index a599ffa888e7d..703fed8d41580 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.7.0"] + "requirements": ["pydrawise==2025.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e8af668240056..2d850b3fc8f8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1934,7 +1934,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.7.0 +pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb0f84fa76fb3..2ae44dec92b4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1615,7 +1615,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.7.0 +pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From 3a615908eeab46a8c347de2b1e6bec913264d0df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 22:55:27 -0500 Subject: [PATCH 35/90] Bump aioharmony to 0.5.3 (#151853) --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index f67eb4db5aab0..f74bff314a454 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.5.2"], + "requirements": ["aioharmony==0.5.3"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 2d850b3fc8f8c..3f77df64a3eb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -262,7 +262,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.5.2 +aioharmony==0.5.3 # homeassistant.components.hassio aiohasupervisor==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ae44dec92b4d..e03ca441459bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -247,7 +247,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.5.2 +aioharmony==0.5.3 # homeassistant.components.hassio aiohasupervisor==0.3.2 From 99b047939f9d88b21b3d6997cadb5a48d67c165d Mon Sep 17 00:00:00 2001 From: Martins Sipenko Date: Sun, 7 Sep 2025 10:36:45 +0300 Subject: [PATCH 36/90] Update pysmarty2 to 0.10.3 (#151855) --- homeassistant/components/smarty/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index c295647b8e56c..fb102a8f9e9f8 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty2"], - "requirements": ["pysmarty2==0.10.2"] + "requirements": ["pysmarty2==0.10.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3f77df64a3eb5..eea9753bc585f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2354,7 +2354,7 @@ pysmarlaapi==0.9.2 pysmartthings==3.2.9 # homeassistant.components.smarty -pysmarty2==0.10.2 +pysmarty2==0.10.3 # homeassistant.components.smhi pysmhi==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e03ca441459bb..c836d2960dbf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1957,7 +1957,7 @@ pysmarlaapi==0.9.2 pysmartthings==3.2.9 # homeassistant.components.smarty -pysmarty2==0.10.2 +pysmarty2==0.10.3 # homeassistant.components.smhi pysmhi==1.0.2 From 9d904c30a77d845ae5b38769c3924a01c83fde1a Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 11 Sep 2025 19:14:59 +0200 Subject: [PATCH 37/90] fix rain sensor for Velux GPU windows (#151857) --- homeassistant/components/velux/binary_sensor.py | 5 +++-- tests/components/velux/test_binary_sensor.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 15d5d2c89add3..de89005fa67de 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -59,5 +59,6 @@ async def async_update(self) -> None: LOGGER.error("Error fetching limitation data for cover %s", self.name) return - # Velux windows with rain sensors report an opening limitation of 93 when rain is detected. - self._attr_is_on = limitation.min_value == 93 + # Velux windows with rain sensors report an opening limitation of 93 or 100 (Velux GPU) when rain is detected. + # So far, only 93 and 100 have been observed in practice, documentation on this is non-existent AFAIK. + self._attr_is_on = limitation.min_value in {93, 100} diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index dfe994b6fa2e3..7afd1a0ee7c0e 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -40,7 +40,14 @@ async def test_rain_sensor_state( assert state is not None assert state.state == STATE_OFF - # simulate rain detected + # simulate rain detected (Velux GPU reports 100) + mock_window.get_limitation.return_value.min_value = 100 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON + + # simulate rain detected (other Velux models report 93) mock_window.get_limitation.return_value.min_value = 93 freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -49,6 +56,13 @@ async def test_rain_sensor_state( assert state is not None assert state.state == STATE_ON + # simulate no rain detected again + mock_window.get_limitation.return_value.min_value = 95 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("mock_module") From f105b45ee2e608f63fcd1ca63da389f392a2f6ae Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 7 Sep 2025 19:52:05 +0200 Subject: [PATCH 38/90] Bump aioecowitt to 2025.9.1 (#151859) --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 0d18933f8775a..ba3d01ef6af3f 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2025.9.0"] + "requirements": ["aioecowitt==2025.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index eea9753bc585f..f63ea3bf2d65f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -238,7 +238,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.0 +aioecowitt==2025.9.1 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c836d2960dbf5..7ffa30f3f285d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -226,7 +226,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.0 +aioecowitt==2025.9.1 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 From def5408db8412e36b85c75df86fd786bf34690aa Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 12 Sep 2025 10:44:22 +0200 Subject: [PATCH 39/90] Use `native_visibility` property instead of `visibility` for OpenWeatherMap weather entity (#151867) --- homeassistant/components/openweathermap/weather.py | 2 +- tests/components/openweathermap/snapshots/test_weather.ambr | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index f182b083b9068..56f44fa46fb7e 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -181,7 +181,7 @@ def wind_bearing(self) -> float | str | None: return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING) @property - def visibility(self) -> float | str | None: + def native_visibility(self) -> float | None: """Return visibility.""" return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE) diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 073715c87ec68..be3db7bc59434 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -72,6 +72,7 @@ 'pressure_unit': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -136,6 +137,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -200,6 +202,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, From 12b409d8e1aceaff7144719a219fc14c75344ca9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 9 Sep 2025 19:42:49 +0200 Subject: [PATCH 40/90] Bump aiontfy to v0.5.5 (#151869) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index f041b02b6d674..ba18dcb4f50a6 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.4"] + "requirements": ["aiontfy==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f63ea3bf2d65f..b7e8c87d0b9e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -325,7 +325,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.4 +aiontfy==0.5.5 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ffa30f3f285d..c24878b80bfc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.4 +aiontfy==0.5.5 # homeassistant.components.nut aionut==4.3.4 From e5b78cc481ecaf7c559f123b0fbec7003786acc9 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 8 Sep 2025 20:45:42 +1000 Subject: [PATCH 41/90] Bump aiolifx-themes to 1.0.2 to support newer LIFX devices (#151898) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 3c755779846be..d7f50ca493bcd 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -54,6 +54,6 @@ "requirements": [ "aiolifx==1.2.1", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.6.4" + "aiolifx-themes==1.0.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index b7e8c87d0b9e7..96c45f8295c0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -298,7 +298,7 @@ aiokem==1.0.1 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.4 +aiolifx-themes==1.0.2 # homeassistant.components.lifx aiolifx==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c24878b80bfc1..2b4d563a63ed0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -280,7 +280,7 @@ aiokem==1.0.1 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.4 +aiolifx-themes==1.0.2 # homeassistant.components.lifx aiolifx==1.2.1 From f07890cf5c4ad1aa356ee5463336ce7ca7189494 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 8 Sep 2025 12:58:26 +0200 Subject: [PATCH 42/90] Bump aiovodafone to 1.2.1 (#151901) --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 4c33cf1a4a5a3..a9ee2f49b4c65 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "platinum", - "requirements": ["aiovodafone==0.10.0"] + "requirements": ["aiovodafone==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 96c45f8295c0d..83a416dac0bdc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.10.0 +aiovodafone==1.2.1 # homeassistant.components.waqi aiowaqi==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4d563a63ed0..284da0163f4f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -408,7 +408,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.10.0 +aiovodafone==1.2.1 # homeassistant.components.waqi aiowaqi==3.1.0 From 087d9d30c046188774bd0d4d14622be2dde7e643 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Sep 2025 21:09:44 +0200 Subject: [PATCH 43/90] Avoid cleanup/recreate of device_trackers not linked to a device for Vodafone Station (#151904) --- .../vodafone_station/coordinator.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 35c32ab2af303..5a3330b16c621 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,11 +8,14 @@ from aiohttp import ClientSession from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions -from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.components.device_tracker import ( + DEFAULT_CONSIDER_HOME, + DOMAIN as DEVICE_TRACKER_DOMAIN, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -71,16 +74,14 @@ def __init__( update_interval=timedelta(seconds=SCAN_INTERVAL), config_entry=config_entry, ) - device_reg = dr.async_get(self.hass) - device_list = dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ) + entity_reg = er.async_get(hass) self.previous_devices = { - connection[1].upper() - for device in device_list - for connection in device.connections - if connection[0] == dr.CONNECTION_NETWORK_MAC + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + if entry.domain == DEVICE_TRACKER_DOMAIN } def _calculate_update_time_and_consider_home( From d6299094db53356af7b4110be3e02f211d5bfa21 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:35:33 +0200 Subject: [PATCH 44/90] Fix _is_valid_suggested_unit in sensor platform (#151912) --- homeassistant/components/sensor/__init__.py | 2 +- tests/components/sensor/test_init.py | 8 + .../tuya/snapshots/test_sensor.ambr | 224 ++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 5617170733871..0268bd8b20718 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -365,7 +365,7 @@ def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool: unit converter supports both the native and the suggested units of measurement. """ # Make sure we can convert the units - if ( + if self.native_unit_of_measurement != suggested_unit_of_measurement and ( (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None or self.__native_unit_of_measurement_compat not in unit_converter.VALID_UNITS diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index ce78edfe481ff..c31abe62826a9 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, STATE_UNKNOWN, EntityCategory, @@ -2938,6 +2939,13 @@ async def test_suggested_unit_guard_invalid_unit( UnitOfDataRate.BITS_PER_SECOND, 10000, ), + ( + SensorDeviceClass.CO2, + CONCENTRATION_PARTS_PER_MILLION, + 10, + CONCENTRATION_PARTS_PER_MILLION, + 10, + ), ], ) async def test_suggested_unit_guard_valid_unit( diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 2a3a93b1b3e75..baee5593ee73c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -400,6 +400,62 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.aqi_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'ppm', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occo2_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'AQI Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.aqi_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '541.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -505,6 +561,62 @@ 'state': '53.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'μg/m³', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocpm25_value', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'AQI PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7063,6 +7175,62 @@ 'state': '42.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kalado_air_purifier_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'μg/m³', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkpm25', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Kalado Air Purifier PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.keller_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11232,6 +11400,62 @@ 'state': '97.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.smogo_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smogo_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'ppm', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocco_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smogo_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Smogo Carbon monoxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.smogo_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4618b33e93118325ce50ae487736c5590eb60672 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 13:37:50 -0500 Subject: [PATCH 45/90] Bump habluetooth to 5.5.1 (#151921) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b4d188550d36b..f2009cb07dc71 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.3.1" + "habluetooth==5.5.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c77c07c1551e5..641ca3fbe1ea8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.3.1 +habluetooth==5.5.1 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 83a416dac0bdc..4fa3c5ed7209f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.3.1 +habluetooth==5.5.1 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 284da0163f4f4..107f40c3d5f15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,7 +995,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.3.1 +habluetooth==5.5.1 # homeassistant.components.cloud hass-nabucasa==1.1.0 From d30ad827743b8ac40085721000a509e5fb216d08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 14:32:31 -0500 Subject: [PATCH 46/90] Bump bleak-esphome to 3.3.0 (#151922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 802ddae36e93c..7253cd79910f3 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.2.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.3.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8dd198d1da1ff..4bf734602ac53 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==39.0.1", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.2.0" + "bleak-esphome==3.3.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4fa3c5ed7209f..d13f08cabca26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==3.2.0 +bleak-esphome==3.3.0 # homeassistant.components.bluetooth bleak-retry-connector==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 107f40c3d5f15..906525d692eaa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -559,7 +559,7 @@ bimmer-connected[china]==0.17.3 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==3.2.0 +bleak-esphome==3.3.0 # homeassistant.components.bluetooth bleak-retry-connector==4.4.3 From 6b934d94db23614f17ecbaa788f38a8d9e2be0df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 16:45:11 -0500 Subject: [PATCH 47/90] Bump habluetooth to 5.6.0 (#151942) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f2009cb07dc71..b87e4d5a2f28b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.5.1" + "habluetooth==5.6.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 641ca3fbe1ea8..cec1d153f6e6c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.5.1 +habluetooth==5.6.0 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d13f08cabca26..2fe29a5674486 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.5.1 +habluetooth==5.6.0 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 906525d692eaa..2ece92735bedf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,7 +995,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.5.1 +habluetooth==5.6.0 # homeassistant.components.cloud hass-nabucasa==1.1.0 From 8c61788a7df856a456fd5a75dfb4d41e8c1ae840 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:44:31 +0200 Subject: [PATCH 48/90] Fix invalid logger in Tuya (#151957) --- homeassistant/components/tuya/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 96ee50a38c902..04d68c4ec5039 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -42,6 +42,6 @@ "documentation": "https://www.home-assistant.io/integrations/tuya", "integration_type": "hub", "iot_class": "cloud_push", - "loggers": ["tuya_iot"], + "loggers": ["tuya_sharing"], "requirements": ["tuya-device-sharing-sdk==0.2.1"] } From a547179f66774629fe267675cd8f8efa8a6f0e51 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:16:19 +0100 Subject: [PATCH 49/90] Fix for squeezebox track content_type (#151963) --- homeassistant/components/squeezebox/browse_media.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index cebd4fcb04f9f..82b6f4b98cdaa 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -116,6 +116,7 @@ MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, "favorite": None, + "track": MediaType.TRACK, } From e5b67d513aa6fee2977ad370cd495613e5078cab Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:22:28 +0100 Subject: [PATCH 50/90] Fix playlist media_class_filter in search_media for squeezebox (#151973) --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a857602a58411..a5f5288807f32 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -607,7 +607,7 @@ async def async_search_media( _media_content_type_list = ( query.media_content_type.lower().replace(", ", ",").split(",") if query.media_content_type - else ["albums", "tracks", "artists", "genres"] + else ["albums", "tracks", "artists", "genres", "playlists"] ) if query.media_content_type and set(_media_content_type_list).difference( From d6ce71fa61983e6f1d9f1629755f1332d2b8d3fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Sep 2025 11:38:45 -0500 Subject: [PATCH 51/90] Bump habluetooth to 5.6.2 (#151985) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b87e4d5a2f28b..ffffc3ec6f3e8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.6.0" + "habluetooth==5.6.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cec1d153f6e6c..5a4a5fbf5c96f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.6.0 +habluetooth==5.6.2 hass-nabucasa==1.1.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 2fe29a5674486..5c53240544156 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.6.0 +habluetooth==5.6.2 # homeassistant.components.cloud hass-nabucasa==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ece92735bedf..08113573e85cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,7 +995,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.6.0 +habluetooth==5.6.2 # homeassistant.components.cloud hass-nabucasa==1.1.0 From 529219ae69a4f6a16192a9bd8d2b7e7332c80930 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 01:28:49 +0200 Subject: [PATCH 52/90] Bump yt-dlp to 2025.09.05 (#152006) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 477e77022de74..beb22dd0858bf 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.08.11"], + "requirements": ["yt-dlp[default]==2025.09.05"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 5c53240544156..de2b7e628cdb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3184,7 +3184,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.08.11 +yt-dlp[default]==2025.09.05 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08113573e85cd..36f9520973c92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2631,7 +2631,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.08.11 +yt-dlp[default]==2025.09.05 # homeassistant.components.zamg zamg==0.3.6 From ab1c2c4f705a0b71181d2c2fcb3cffb954c8ed94 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 10 Sep 2025 12:25:20 +0200 Subject: [PATCH 53/90] Bump `accuweather` to version 4.2.1 (#152029) --- .../components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/accuweather/test_config_flow.py | 18 ------------------ 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 810557519eb24..9f3c8c7932a79 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.2.0"], + "requirements": ["accuweather==4.2.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index de2b7e628cdb6..5c78ed75c8b9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.0 +accuweather==4.2.1 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36f9520973c92..0fedb5950d415 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.0 +accuweather==4.2.1 # homeassistant.components.adax adax==0.4.0 diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index abe1be61905d5..63ad8bf551300 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -30,24 +30,6 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_api_key_too_short(hass: HomeAssistant) -> None: - """Test that errors are shown when API key is too short.""" - # The API key length check is done by the library without polling the AccuWeather - # server so we don't need to patch the library method. - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ - CONF_NAME: "abcd", - CONF_API_KEY: "foo", - CONF_LATITUDE: 55.55, - CONF_LONGITUDE: 122.12, - }, - ) - - assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} - - async def test_invalid_api_key( hass: HomeAssistant, mock_accuweather_client: AsyncMock ) -> None: From 2e33222c7128b0cb750e1664976fbb5a2707de86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 19:45:22 -0500 Subject: [PATCH 54/90] Fix HomeKit Controller stale values at startup (#152086) Co-authored-by: TheJulianJES --- .../homekit_controller/connection.py | 54 ++++++++++-- .../homekit_controller/test_connection.py | 87 +++++++++++++++++++ 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 139ceef48adf0..ce8dc498d6d51 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -20,7 +20,12 @@ EncryptionError, ) from aiohomekit.model import Accessories, Accessory, Transport -from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics import ( + EVENT_CHARACTERISTICS, + Characteristic, + CharacteristicPermissions, + CharacteristicsTypes, +) from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.thread import async_get_preferred_dataset @@ -179,6 +184,21 @@ def remove_pollable_characteristics( for aid_iid in characteristics: self.pollable_characteristics.discard(aid_iid) + def get_all_pollable_characteristics(self) -> set[tuple[int, int]]: + """Get all characteristics that can be polled. + + This is used during startup to poll all readable characteristics + before entities have registered what they care about. + """ + return { + (accessory.aid, char.iid) + for accessory in self.entity_map.accessories + for service in accessory.services + for char in service.characteristics + if CharacteristicPermissions.paired_read in char.perms + and char.type not in EVENT_CHARACTERISTICS + } + def add_watchable_characteristics( self, characteristics: list[tuple[int, int]] ) -> None: @@ -309,9 +329,13 @@ async def async_setup(self) -> None: await self.async_process_entity_map() if transport != Transport.BLE: - # Do a single poll to make sure the chars are - # up to date so we don't restore old data. - await self.async_update() + # When Home Assistant starts, we restore the accessory map from storage + # which contains characteristic values from when HA was last running. + # These values are stale and may be incorrect (e.g., Ecobee thermostats + # report 100°C when restarting). We need to poll for fresh values before + # creating entities. Use poll_all=True since entities haven't registered + # their characteristics yet. + await self.async_update(poll_all=True) self._async_start_polling() # If everything is up to date, we can create the entities @@ -863,9 +887,25 @@ async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" await self._debounced_update.async_call() - async def async_update(self, now: datetime | None = None) -> None: - """Poll state of all entities attached to this bridge/accessory.""" - to_poll = self.pollable_characteristics + async def async_update( + self, now: datetime | None = None, *, poll_all: bool = False + ) -> None: + """Poll state of all entities attached to this bridge/accessory. + + Args: + now: The current time (used by time interval callbacks). + poll_all: If True, poll all readable characteristics instead + of just the registered ones. + This is useful during initial setup before entities have + registered their characteristics. + """ + if poll_all: + # Poll all readable characteristics during initial startup + # excluding device trigger characteristics (buttons, doorbell, etc.) + to_poll = self.get_all_pollable_characteristics() + else: + to_poll = self.pollable_characteristics + if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 00c7bb1625991..99203d400fea5 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -2,6 +2,7 @@ from collections.abc import Callable import dataclasses +from typing import Any from unittest import mock from aiohomekit.controller import TransportType @@ -11,6 +12,7 @@ from aiohomekit.testing import FakeController import pytest +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE from homeassistant.components.homekit_controller.const import ( DEBOUNCE_COOLDOWN, DOMAIN, @@ -439,3 +441,88 @@ def _create_accessory(accessory: Accessory) -> Service: await time_changed(hass, DEBOUNCE_COOLDOWN) await hass.async_block_till_done() assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 + + +async def test_poll_all_on_startup_refreshes_stale_values( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test that entities get fresh values on startup instead of stale stored values.""" + # Load actual Ecobee accessory fixture + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + + # Pre-populate storage with the accessories data (already has stale values) + hass_storage["homekit_controller-entity-map"] = { + "version": 1, + "minor_version": 1, + "key": "homekit_controller-entity-map", + "data": { + "pairings": { + "00:00:00:00:00:00": { + "config_num": 1, + "accessories": [ + a.to_accessory_and_service_list() for a in accessories + ], + } + } + }, + } + + # Track what gets polled during setup + polled_chars: list[tuple[int, int]] = [] + + # Set up the test accessories + fake_controller = await setup_platform(hass) + + # Mock get_characteristics to track polling and return fresh temperature + async def mock_get_characteristics( + chars: set[tuple[int, int]], **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: + """Return fresh temperature value when polled.""" + polled_chars.extend(chars) + # Return fresh values for all characteristics + result: dict[tuple[int, int], dict[str, Any]] = {} + for aid, iid in chars: + # Find the characteristic and return appropriate value + for accessory in accessories: + if accessory.aid != aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != iid: + continue + # Return fresh temperature instead of stale fixture value + if char.type == CharacteristicsTypes.TEMPERATURE_CURRENT: + result[(aid, iid)] = {"value": 22.5} # Fresh value + else: + result[(aid, iid)] = {"value": char.value} + break + return result + + # Add the paired device with our mock + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + ) + config_entry.add_to_hass(hass) + + # Get the pairing and patch its get_characteristics + pairing = fake_controller.pairings["00:00:00:00:00:00"] + + with mock.patch.object(pairing, "get_characteristics", mock_get_characteristics): + # Set up the config entry (this should trigger poll_all=True) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that polling happened during setup (poll_all=True was used) + assert ( + len(polled_chars) == 79 + ) # The Ecobee fixture has exactly 79 readable characteristics + + # Check that the climate entity has the fresh temperature (22.5°C) not the stale fixture value (21.8°C) + state = hass.states.get("climate.homew") + assert state is not None + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 From 9c749a6abc399cb0127e80571d5c8831cac080a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 11 Sep 2025 14:47:59 +0100 Subject: [PATCH 55/90] Fix duplicated IP port usage in Govee Light Local (#152087) --- .../components/govee_light_local/__init__.py | 19 ++++++++++------ .../govee_light_local/config_flow.py | 17 +++++--------- .../govee_light_local/coordinator.py | 5 ++--- .../govee_light_local/test_config_flow.py | 22 ++++++++++++------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 803f4b3ead543..4315f5d5363d8 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -26,16 +26,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Set up Govee light local from a config entry.""" - # Get source IPs for all enabled adapters - source_ips = await network.async_get_enabled_source_ips(hass) + source_ips = await async_get_source_ips(hass) _LOGGER.debug("Enabled source IPs: %s", source_ips) coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator( - hass=hass, - config_entry=entry, - source_ips=[ - source_ip for source_ip in source_ips if isinstance(source_ip, IPv4Address) - ], + hass=hass, config_entry=entry, source_ips=source_ips ) async def await_cleanup(): @@ -76,3 +71,13 @@ async def await_cleanup(): async def async_unload_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_get_source_ips( + hass: HomeAssistant, +) -> set[str]: + """Get the source ips for Govee local.""" + source_ips = await network.async_get_enabled_source_ips(hass) + return { + str(source_ip) for source_ip in source_ips if isinstance(source_ip, IPv4Address) + } diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index a1f601b288893..cd1dc00f9e0e1 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -4,15 +4,14 @@ import asyncio from contextlib import suppress -from ipaddress import IPv4Address import logging from govee_local_api import GoveeController -from homeassistant.components import network from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow +from . import async_get_source_ips from .const import ( CONF_LISTENING_PORT_DEFAULT, CONF_MULTICAST_ADDRESS_DEFAULT, @@ -24,11 +23,11 @@ _LOGGER = logging.getLogger(__name__) -async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool: +async def _async_discover(hass: HomeAssistant, adapter_ip: str) -> bool: controller: GoveeController = GoveeController( loop=hass.loop, logger=_LOGGER, - listening_address=str(adapter_ip), + listening_address=adapter_ip, broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, broadcast_port=CONF_TARGET_PORT_DEFAULT, listening_port=CONF_LISTENING_PORT_DEFAULT, @@ -62,14 +61,8 @@ async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool: async def _async_has_devices(hass: HomeAssistant) -> bool: """Return if there are devices that can be discovered.""" - # Get source IPs for all enabled adapters - source_ips = await network.async_get_enabled_source_ips(hass) - _LOGGER.debug("Enabled source IPs: %s", source_ips) - - # Run discovery on every IPv4 address and gather results - results = await asyncio.gather( - *[_async_discover(hass, ip) for ip in source_ips if isinstance(ip, IPv4Address)] - ) + source_ips = await async_get_source_ips(hass) + results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips]) return any(results) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 31efeb55680d2..1c2aac12f70c6 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Callable -from ipaddress import IPv4Address import logging from govee_local_api import GoveeController, GoveeDevice @@ -30,7 +29,7 @@ def __init__( self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry, - source_ips: list[IPv4Address], + source_ips: set[str], ) -> None: """Initialize my coordinator.""" super().__init__( @@ -45,7 +44,7 @@ def __init__( GoveeController( loop=hass.loop, logger=_LOGGER, - listening_address=str(source_ip), + listening_address=source_ip, broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, broadcast_port=CONF_TARGET_PORT_DEFAULT, listening_port=CONF_LISTENING_PORT_DEFAULT, diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index e6e336a70f29a..32ef2408c013a 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -1,6 +1,7 @@ """Test Govee light local config flow.""" from errno import EADDRINUSE +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch from govee_local_api import GoveeDevice @@ -61,17 +62,22 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + # Mock duplicated IPs to ensure that only one GoveeController is started + with patch( + "homeassistant.components.network.async_get_enabled_source_ips", + return_value=[IPv4Address("192.168.1.2"), IPv4Address("192.168.1.2")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() mock_govee_api.start.assert_awaited_once() mock_setup_entry.assert_awaited_once() From 9a165a64fe28c662efd121f365b3e60f697abdcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 09:58:59 -0500 Subject: [PATCH 56/90] Fix DoorBird being updated with wrong IP addresses during discovery (#152088) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/doorbird/config_flow.py | 56 ++++++++++++- .../components/doorbird/strings.json | 2 + tests/components/doorbird/test_config_flow.py | 84 ++++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 6a954f5310ffd..ac08ad0e1f624 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -19,8 +19,10 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType @@ -103,6 +105,43 @@ def __init__(self) -> None: """Initialize the DoorBird config flow.""" self.discovery_schema: vol.Schema | None = None + async def _async_verify_existing_device_for_discovery( + self, + existing_entry: ConfigEntry, + host: str, + macaddress: str, + ) -> None: + """Verify discovered device matches existing entry before updating IP. + + This method performs the following verification steps: + 1. Ensures that the stored credentials work before updating the entry. + 2. Verifies that the device at the discovered IP address has the expected MAC address. + """ + info, errors = await self._async_validate_or_error( + { + **existing_entry.data, + CONF_HOST: host, + } + ) + + if errors: + _LOGGER.debug( + "Cannot validate DoorBird at %s with existing credentials: %s", + host, + errors, + ) + raise AbortFlow("cannot_connect") + + # Verify the MAC address matches what was advertised + if format_mac(info["mac_addr"]) != format_mac(macaddress): + _LOGGER.debug( + "DoorBird at %s reports MAC %s but zeroconf advertised %s, ignoring", + host, + info["mac_addr"], + macaddress, + ) + raise AbortFlow("wrong_device") + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -172,7 +211,22 @@ async def async_step_zeroconf( await self.async_set_unique_id(macaddress) host = discovery_info.host - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + # Check if we have an existing entry for this MAC + existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, macaddress + ) + + if existing_entry: + # Check if the host is actually changing + if existing_entry.data.get(CONF_HOST) != host: + await self._async_verify_existing_device_for_discovery( + existing_entry, host, macaddress + ) + + # All checks passed or no change needed, abort + # if already configured with potential IP update + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 285b544e465b7..341976e8a8f16 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -49,6 +49,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "link_local_address": "Link local addresses are not supported", "not_doorbird_device": "This device is not a DoorBird", + "not_ipv4_address": "Only IPv4 addresses are supported", + "wrong_device": "Device MAC address does not match", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "flow_title": "{name} ({host})", diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 98b2189dfd97e..493762df5ef7d 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -108,7 +108,9 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: assert result["reason"] == "link_local_address" -async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: +async def test_form_zeroconf_ipv4_address( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: """Test we abort and update the ip address from zeroconf with an ipv4 address.""" config_entry = MockConfigEntry( @@ -118,6 +120,13 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: options={CONF_EVENTS: ["event1", "event2", "event3"]}, ) config_entry.add_to_hass(hass) + + # Mock the API to return the correct MAC when validating + doorbird_api.info.return_value = { + "PRIMARY_MAC_ADDR": "1CCAE3AAAAAA", + "WIFI_MAC_ADDR": "1CCAE3BBBBBB", + } + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -136,6 +145,79 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: assert config_entry.data[CONF_HOST] == "4.4.4.4" +async def test_form_zeroconf_ipv4_address_wrong_device( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: + """Test we abort when the device MAC doesn't match during zeroconf update.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1CCAE3AAAAAA", + data=VALID_CONFIG, + options={CONF_EVENTS: ["event1", "event2", "event3"]}, + ) + config_entry.add_to_hass(hass) + + # Mock the API to return a different MAC (wrong device) + doorbird_api.info.return_value = { + "PRIMARY_MAC_ADDR": "1CCAE3DIFFERENT", # Different MAC! + "WIFI_MAC_ADDR": "1CCAE3BBBBBB", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3AAAAAA"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + # Host should not be updated since it's the wrong device + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + +async def test_form_zeroconf_ipv4_address_cannot_connect( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: + """Test we abort when we cannot connect to validate during zeroconf update.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1CCAE3AAAAAA", + data=VALID_CONFIG, + options={CONF_EVENTS: ["event1", "event2", "event3"]}, + ) + config_entry.add_to_hass(hass) + + # Mock the API to fail connection (e.g., wrong credentials or network error) + doorbird_api.info.side_effect = mock_unauthorized_exception() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3AAAAAA"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + # Host should not be updated since we couldn't validate + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: """Test we abort when we get a non ipv4 address via zeroconf.""" From d2e753762913fb762869cca438b2fe0074403160 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Sep 2025 19:43:44 +0200 Subject: [PATCH 57/90] Fix supported _color_modes attribute not set for on/off MQTT JSON light (#152126) --- homeassistant/components/mqtt/light/schema_json.py | 2 ++ tests/components/mqtt/test_light_json.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index fc76d4bcf6c61..f71a333dbe108 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -223,6 +223,8 @@ def _setup_from_config(self, config: ConfigType) -> None: # Brightness is supported and no supported_color_modes are set, # so set brightness as the supported color mode. self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + else: + self._attr_supported_color_modes = {ColorMode.ONOFF} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7f7f32c4e43ab..8c32926e08e87 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -182,6 +182,19 @@ def __eq__(self, other: bytes | str) -> bool: # type:ignore[override] return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_simple_on_off_light( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if setup fails with no command topic.""" + assert await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state and state.state == STATE_UNKNOWN + assert state.attributes["supported_color_modes"] == ["onoff"] + + @pytest.mark.parametrize( "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] ) From 14173bd9ecf05613870ba7fbab88e3ae197d2a2a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 11 Sep 2025 22:26:47 +0200 Subject: [PATCH 58/90] Fix reauth for Alexa Devices (#152128) --- .../components/alexa_devices/config_flow.py | 7 +++- tests/components/alexa_devices/conftest.py | 1 + .../alexa_devices/test_config_flow.py | 38 +++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index f266a8688547e..a3bcce1965b27 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -107,7 +107,9 @@ async def async_step_reauth_confirm( if user_input is not None: try: - await validate_input(self.hass, {**reauth_entry.data, **user_input}) + data = await validate_input( + self.hass, {**reauth_entry.data, **user_input} + ) except CannotConnect: errors["base"] = "cannot_connect" except (CannotAuthenticate, TypeError): @@ -119,8 +121,9 @@ async def async_step_reauth_confirm( reauth_entry, data={ CONF_USERNAME: entry_data[CONF_USERNAME], - CONF_PASSWORD: entry_data[CONF_PASSWORD], + CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_CODE: user_input[CONF_CODE], + CONF_LOGIN_DATA: data, }, ) diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 2ef2c2431dc13..7397b4b72fb53 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -45,6 +45,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client = mock_client.return_value client.login_mode_interactive.return_value = { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { TEST_SERIAL_NUMBER: AmazonDevice( diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9aea6fe4c44fd..4722f9c0c5f26 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -9,7 +9,11 @@ ) import pytest -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -48,6 +52,7 @@ async def test_full_flow( CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", }, } assert result["result"].unique_id == TEST_USERNAME @@ -158,6 +163,16 @@ async def test_reauth_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_CODE: "000000", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "other_fake_password", + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } + @pytest.mark.parametrize( ("side_effect", "error"), @@ -206,8 +221,15 @@ async def test_reauth_not_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" - assert mock_config_entry.data[CONF_CODE] == "111111" + assert mock_config_entry.data == { + CONF_CODE: "111111", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "fake_password", + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } async def test_reconfigure_successful( @@ -240,7 +262,14 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data[CONF_PASSWORD] == new_password + assert mock_config_entry.data == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: new_password, + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } @pytest.mark.parametrize( @@ -297,5 +326,6 @@ async def test_reconfigure_fails( CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", }, } From dc09e335564c09079a69df273109102e9b873b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 12 Sep 2025 10:18:58 +0200 Subject: [PATCH 59/90] Bump hass-nabucasa from 1.1.0 to 1.1.1 (#152147) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 43cdf17740a8a..0625054869d88 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.1.0"], + "requirements": ["hass-nabucasa==1.1.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a4a5fbf5c96f..f12009ee9396a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.2 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250903.3 diff --git a/pyproject.toml b/pyproject.toml index ee06c96403bb5..abcd1e0d5491a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.1.0", + "hass-nabucasa==1.1.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index d1de18296ff53..381d1fd98d8d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5c78ed75c8b9b..ceff657f574d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1137,7 +1137,7 @@ habiticalib==0.4.3 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fedb5950d415..5384b8661f0d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ habiticalib==0.4.3 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation From a764d541239ef2fbf59d8bd57329a190f2484814 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 12 Sep 2025 18:01:16 +0200 Subject: [PATCH 60/90] Update frontend to 20250903.5 (#152170) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d74bf1f30b7f9..44dff45029936 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250903.3"] + "requirements": ["home-assistant-frontend==20250903.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f12009ee9396a..587fff7deb524 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==5.6.2 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 home-assistant-intents==2025.9.3 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ceff657f574d2..10ff4d25da7d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1178,7 +1178,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 # homeassistant.components.conversation home-assistant-intents==2025.9.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5384b8661f0d8..d288c67d03cc0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 # homeassistant.components.conversation home-assistant-intents==2025.9.3 From 91a7db08ffa8b9599dbd1bd1a34a28e6ee7ff4f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 12 Sep 2025 20:20:56 +0000 Subject: [PATCH 61/90] Bump version to 2025.9.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 318594196e27a..17a8523d069d5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index abcd1e0d5491a..cf16f18158296 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.9.1" +version = "2025.9.2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 1c10b85fed57cb8c13ea586c405d1aef4ae3b5d8 Mon Sep 17 00:00:00 2001 From: wollew Date: Fri, 5 Sep 2025 17:45:29 +0200 Subject: [PATCH 62/90] Use position percentage for closed status in Velux (#151679) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 4 +- homeassistant/components/velux/cover.py | 16 +++++-- homeassistant/components/velux/manifest.json | 2 +- tests/components/velux/__init__.py | 30 ++++++++++++ tests/components/velux/conftest.py | 3 ++ tests/components/velux/test_binary_sensor.py | 13 ++---- tests/components/velux/test_cover.py | 48 ++++++++++++++++++++ 7 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 tests/components/velux/test_cover.py diff --git a/CODEOWNERS b/CODEOWNERS index d1f06d04b41d4..d514bb18bafb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1690,8 +1690,8 @@ build.json @home-assistant/supervisor /tests/components/vegehub/ @ghowevege /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio -/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 32be29c3c9142..f31c4877ffdd0 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -4,8 +4,15 @@ from typing import Any, cast -from pyvlx import OpeningDevice, Position -from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter +from pyvlx import ( + Awning, + Blind, + GarageDoor, + Gate, + OpeningDevice, + Position, + RollerShutter, +) from homeassistant.components.cover import ( ATTR_POSITION, @@ -97,7 +104,10 @@ def current_cover_tilt_position(self) -> int | None: @property def is_closed(self) -> bool: """Return if the cover is closed.""" - return self.node.position.closed + # do not use the node's closed state but rely on cover position + # until https://github.com/Julius2342/pyvlx/pull/543 is merged. + # once merged this can again return self.node.position.closed + return self.current_cover_position == 0 @property def is_opening(self) -> bool: diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index cb21fef299df2..11e939fdfe77b 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,7 +1,7 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py index 6cf5cd366fb15..b50a46b1150db 100644 --- a/tests/components/velux/__init__.py +++ b/tests/components/velux/__init__.py @@ -1 +1,31 @@ """Tests for the Velux integration.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.helpers.device_registry import HomeAssistant +from homeassistant.helpers.entity_platform import timedelta + +from tests.common import async_fire_time_changed + + +async def update_callback_entity( + hass: HomeAssistant, mock_velux_node: MagicMock +) -> None: + """Simulate an update triggered by the pyvlx lib for a Velux node.""" + + callback = mock_velux_node.register_device_updated_cb.call_args[0][0] + await callback(mock_velux_node) + await hass.async_block_till_done() + + +async def update_polled_entities( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Simulate an update trigger from polling.""" + # just fire a time changed event to trigger the polling + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 1b7066577ad4d..22fc1a933579d 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -72,6 +72,9 @@ def mock_window() -> AsyncMock: window.rain_sensor = True window.serial_number = "123456789" window.get_limitation.return_value = MagicMock(min_value=0) + window.is_opening = False + window.is_closing = False + window.position = MagicMock(position_percent=30, closed=False) return window diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 7afd1a0ee7c0e..b7048173a6579 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the Velux binary sensor platform.""" -from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -11,7 +10,9 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from tests.common import MockConfigEntry, async_fire_time_changed +from . import update_polled_entities + +from tests.common import MockConfigEntry @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -33,9 +34,7 @@ async def test_rain_sensor_state( test_entity_id = "binary_sensor.test_window_rain_sensor" # simulate no rain detected - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_OFF @@ -49,9 +48,7 @@ async def test_rain_sensor_state( # simulate rain detected (other Velux models report 93) mock_window.get_limitation.return_value.min_value = 93 - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py new file mode 100644 index 0000000000000..621aa1c3b6c9f --- /dev/null +++ b/tests/components/velux/test_cover.py @@ -0,0 +1,48 @@ +"""Tests for the Velux cover platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_CLOSED, STATE_OPEN, Platform +from homeassistant.core import HomeAssistant + +from . import update_callback_entity + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_module") +async def test_cover_closed( + hass: HomeAssistant, + mock_window: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the cover closed state.""" + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.COVER]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + test_entity_id = "cover.test_window" + + # Initial state should be open + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OPEN + + # Update mock window position to closed percentage + mock_window.position.position_percent = 100 + # Also directly set position to closed, so this test should + # continue to be green after the lib is fixed + mock_window.position.closed = True + + # Trigger entity state update via registered callback + await update_callback_entity(hass, mock_window) + + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_CLOSED From 40988198f36efd96e353322faa899baa52521145 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 06:04:58 -0500 Subject: [PATCH 63/90] Bump habluetooth to 5.6.4 (#152227) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ffffc3ec6f3e8..431ec10b366d4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.6.2" + "habluetooth==5.6.4" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 587fff7deb524..7da074c28eaad 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.6.2 +habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 10ff4d25da7d1..8b1d6bd2f33b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.6.2 +habluetooth==5.6.4 # homeassistant.components.cloud hass-nabucasa==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d288c67d03cc0..67b707eb26123 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,7 +995,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.3 # homeassistant.components.bluetooth -habluetooth==5.6.2 +habluetooth==5.6.4 # homeassistant.components.cloud hass-nabucasa==1.1.1 From 2c809d59033840f2d0af8a677e830996f59ad6ae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 13 Sep 2025 12:14:15 +0000 Subject: [PATCH 64/90] Bump version to 2025.9.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 17a8523d069d5..217247dedfcc6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index cf16f18158296..ef4603faa8186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.9.2" +version = "2025.9.3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From eac719f9afb4d121b1c05c3190698e780b6f6496 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 29 Aug 2025 00:04:37 +0200 Subject: [PATCH 65/90] Bump habiticalib to v0.4.4 (#151332) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 99c84f9686f5e..86002107a6873 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.3"] + "requirements": ["habiticalib==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b1d6bd2f33b5..65494bafc3631 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.3 +habiticalib==0.4.4 # homeassistant.components.bluetooth habluetooth==5.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67b707eb26123..b0ae6b9dd408e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -992,7 +992,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.3 +habiticalib==0.4.4 # homeassistant.components.bluetooth habluetooth==5.6.4 From 8920c548d508ce5199d73460cc295f544ddf74b8 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:58:28 +0200 Subject: [PATCH 66/90] Bump habiticalib to v0.4.5 (#151720) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 86002107a6873..30443f1d1da82 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.4"] + "requirements": ["habiticalib==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65494bafc3631..89d03562f6534 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.4 +habiticalib==0.4.5 # homeassistant.components.bluetooth habluetooth==5.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ae6b9dd408e..89d9b6d11a8fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -992,7 +992,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.4 +habiticalib==0.4.5 # homeassistant.components.bluetooth habluetooth==5.6.4 From b30667a469d1653fa62b5e6a0ef511b1529144c5 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Tue, 16 Sep 2025 21:18:29 +0200 Subject: [PATCH 67/90] Fix bug with the hardcoded configuration_url (asuswrt) (#151858) --- homeassistant/components/asuswrt/bridge.py | 7 +++++++ homeassistant/components/asuswrt/router.py | 2 +- tests/components/asuswrt/conftest.py | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 3e3e372108b90..ee7df3ed7b811 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -120,11 +120,17 @@ def get_bridge( def __init__(self, host: str) -> None: """Initialize Bridge.""" + self._configuration_url = f"http://{host}" self._host = host self._firmware: str | None = None self._label_mac: str | None = None self._model: str | None = None + @property + def configuration_url(self) -> str: + """Return configuration URL.""" + return self._configuration_url + @property def host(self) -> str: """Return hostname.""" @@ -359,6 +365,7 @@ async def async_connect(self) -> None: # get main router properties if mac := _identity.mac: self._label_mac = format_mac(mac) + self._configuration_url = self._api.webpanel self._firmware = str(_identity.firmware) self._model = _identity.model diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index c777535e242aa..01c83dfc3ee0d 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -388,11 +388,11 @@ def update_options(self, new_options: Mapping[str, Any]) -> bool: def device_info(self) -> DeviceInfo: """Return the device information.""" info = DeviceInfo( + configuration_url=self._api.configuration_url, identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")}, name=self.host, model=self._api.model or "Asus Router", manufacturer="Asus", - configuration_url=f"http://{self.host}", ) if self._api.firmware: info["sw_version"] = self._api.firmware diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 95c8f3dbf747e..3741aa44559e5 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -12,6 +12,7 @@ from .common import ( ASUSWRT_BASE, + HOST, MOCK_MACS, PROTOCOL_HTTP, PROTOCOL_SSH, @@ -155,6 +156,9 @@ def mock_controller_connect_http(mock_devices_http): # Simulate connection status instance.connected = True + # Set the webpanel address + instance.webpanel = f"http://{HOST}:80" + # Identity instance.async_get_identity.return_value = AsusDevice( mac=ROUTER_MAC_ADDR, From 8d8e008123b700c845f04ad911cc7e0350f238b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 12:24:05 -0500 Subject: [PATCH 68/90] Fix HomeKit Controller overwhelming resource-limited devices by batching characteristic polling (#152209) --- .../homekit_controller/connection.py | 55 +++++++---- .../homekit_controller/test_connection.py | 96 ++++++++++++++++++- 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index ce8dc498d6d51..e20842d186f95 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -57,7 +57,10 @@ RETRY_INTERVAL = 60 # seconds MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3 - +# HomeKit accessories have varying limits on how many characteristics +# they can handle per request. Since we don't know each device's specific limit, +# we batch requests to a conservative size to avoid overwhelming any device. +MAX_CHARACTERISTICS_PER_REQUEST = 49 BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds @@ -326,16 +329,20 @@ async def async_setup(self) -> None: ) entry.async_on_unload(self._async_cancel_subscription_timer) + if transport != Transport.BLE: + # Although async_populate_accessories_state fetched the accessory database, + # the /accessories endpoint may return cached values from the accessory's + # perspective. For example, Ecobee thermostats may report stale temperature + # values (like 100°C) in their /accessories response after restarting. + # We need to explicitly poll characteristics to get fresh sensor readings + # before processing the entity map and creating devices. + # Use poll_all=True since entities haven't registered their characteristics yet. + await self.async_update(poll_all=True) + await self.async_process_entity_map() if transport != Transport.BLE: - # When Home Assistant starts, we restore the accessory map from storage - # which contains characteristic values from when HA was last running. - # These values are stale and may be incorrect (e.g., Ecobee thermostats - # report 100°C when restarting). We need to poll for fresh values before - # creating entities. Use poll_all=True since entities haven't registered - # their characteristics yet. - await self.async_update(poll_all=True) + # Start regular polling after entity map is processed self._async_start_polling() # If everything is up to date, we can create the entities @@ -938,20 +945,26 @@ async def async_update( async with self._polling_lock: _LOGGER.debug("Starting HomeKit device update: %s", self.unique_id) - try: - new_values_dict = await self.get_characteristics(to_poll) - except AccessoryNotFoundError: - # Not only did the connection fail, but also the accessory is not - # visible on the network. - self.async_set_available_state(False) - return - except (AccessoryDisconnectedError, EncryptionError): - # Temporary connection failure. Device may still available but our - # connection was dropped or we are reconnecting - self._poll_failures += 1 - if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE: + new_values_dict: dict[tuple[int, int], dict[str, Any]] = {} + to_poll_list = list(to_poll) + + for i in range(0, len(to_poll_list), MAX_CHARACTERISTICS_PER_REQUEST): + batch = to_poll_list[i : i + MAX_CHARACTERISTICS_PER_REQUEST] + try: + batch_values = await self.get_characteristics(batch) + new_values_dict.update(batch_values) + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. self.async_set_available_state(False) - return + return + except (AccessoryDisconnectedError, EncryptionError): + # Temporary connection failure. Device may still available but our + # connection was dropped or we are reconnecting + self._poll_failures += 1 + if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE: + self.async_set_available_state(False) + return self._poll_failures = 0 self.process_new_events(new_values_dict) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 99203d400fea5..6c5ccdfd8b0a9 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -13,6 +13,9 @@ import pytest from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE +from homeassistant.components.homekit_controller.connection import ( + MAX_CHARACTERISTICS_PER_REQUEST, +) from homeassistant.components.homekit_controller.const import ( DEBOUNCE_COOLDOWN, DOMAIN, @@ -377,9 +380,15 @@ def _create_accessory(accessory: Accessory) -> Service: state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify everything is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} + # Verify everything is polled (convert to set for comparison since batching changes the type) + assert set(mock_get_characteristics.call_args_list[0][0][0]) == { + (1, 10), + (1, 11), + } + assert set(mock_get_characteristics.call_args_list[1][0][0]) == { + (1, 10), + (1, 11), + } # Test device goes offline helper.pairing.available = False @@ -526,3 +535,84 @@ async def mock_get_characteristics( state = hass.states.get("climate.homew") assert state is not None assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + + +async def test_characteristic_polling_batching( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that characteristic polling is batched to MAX_CHARACTERISTICS_PER_REQUEST.""" + + # Create a large accessory with many characteristics (more than 49) + def create_large_accessory_with_many_chars(accessory: Accessory) -> None: + """Create an accessory with many characteristics to test batching.""" + # Add multiple services with many characteristics each + for service_num in range(10): # 10 services + service = accessory.add_service( + ServicesTypes.LIGHTBULB, name=f"Light {service_num}" + ) + # Each lightbulb service gets several characteristics + service.add_char(CharacteristicsTypes.ON) + service.add_char(CharacteristicsTypes.BRIGHTNESS) + service.add_char(CharacteristicsTypes.HUE) + service.add_char(CharacteristicsTypes.SATURATION) + service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE) + # Set initial values + for char in service.characteristics: + if char.type != CharacteristicsTypes.IDENTIFY: + char.value = 0 + + helper = await setup_test_component( + hass, get_next_aid(), create_large_accessory_with_many_chars + ) + + # Track the get_characteristics calls + get_chars_calls = [] + original_get_chars = helper.pairing.get_characteristics + + async def mock_get_characteristics(chars): + """Mock get_characteristics to track batch sizes.""" + get_chars_calls.append(list(chars)) + return await original_get_chars(chars) + + # Clear any calls from setup + get_chars_calls.clear() + + # Patch get_characteristics to track calls + with mock.patch.object( + helper.pairing, "get_characteristics", side_effect=mock_get_characteristics + ): + # Trigger an update through time_changed which simulates regular polling + # time_changed expects seconds, not a datetime + await time_changed(hass, 300) # 5 minutes in seconds + await hass.async_block_till_done() + + # We created 10 lightbulb services with 5 characteristics each = 50 total + # Plus any base accessory characteristics that are pollable + # This should result in exactly 2 batches + assert len(get_chars_calls) == 2, ( + f"Should have made exactly 2 batched calls, got {len(get_chars_calls)}" + ) + + # Check that no batch exceeded MAX_CHARACTERISTICS_PER_REQUEST + for i, batch in enumerate(get_chars_calls): + assert len(batch) <= MAX_CHARACTERISTICS_PER_REQUEST, ( + f"Batch {i} size {len(batch)} exceeded maximum {MAX_CHARACTERISTICS_PER_REQUEST}" + ) + + # Verify the total number of characteristics polled + total_chars = sum(len(batch) for batch in get_chars_calls) + # Each lightbulb has: ON, BRIGHTNESS, HUE, SATURATION, COLOR_TEMPERATURE = 5 + # 10 lightbulbs = 50 characteristics + assert total_chars == 50, ( + f"Should have polled exactly 50 characteristics, got {total_chars}" + ) + + # The first batch should be full (49 characteristics) + assert len(get_chars_calls[0]) == 49, ( + f"First batch should have exactly 49 characteristics, got {len(get_chars_calls[0])}" + ) + + # The second batch should have exactly 1 characteristic + assert len(get_chars_calls[1]) == 1, ( + f"Second batch should have exactly 1 characteristic, got {len(get_chars_calls[1])}" + ) From c87dba878dea9636ad958be406f4576fb02914ee Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Sat, 13 Sep 2025 10:36:15 -0400 Subject: [PATCH 69/90] Upgrade waterfurnace to 1.2.0 (#152241) --- homeassistant/components/waterfurnace/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 2bf72acb047b7..98d21dd9425ec 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "legacy", - "requirements": ["waterfurnace==1.1.0"] + "requirements": ["waterfurnace==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 89d03562f6534..a14a56f56cb93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3090,7 +3090,7 @@ wallbox==0.9.0 watchdog==6.0.0 # homeassistant.components.waterfurnace -waterfurnace==1.1.0 +waterfurnace==1.2.0 # homeassistant.components.watergate watergate-local-api==2024.4.1 From 54859e8a83f1bb50117d6b7dc4378dada65bca01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 12:44:34 -0500 Subject: [PATCH 70/90] Bump aiohomekit to 3.2.16 (#152255) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index d15479aa9d5b3..ef4fdadb24c41 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.15"], + "requirements": ["aiohomekit==3.2.16"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a14a56f56cb93..7e0de86a8d873 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -271,7 +271,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.15 +aiohomekit==3.2.16 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89d9b6d11a8fc..24aeff1a21a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -256,7 +256,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.15 +aiohomekit==3.2.16 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From 265f5da21a11a6ca4a896655385540ff8f2a94f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 13:19:37 -0500 Subject: [PATCH 71/90] Bump bluetooth-auto-recovery to 1.5.3 (#152256) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 431ec10b366d4..bf5345e0ba476 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak==1.0.1", "bleak-retry-connector==4.4.3", "bluetooth-adapters==2.1.0", - "bluetooth-auto-recovery==1.5.2", + "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", "habluetooth==5.6.4" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7da074c28eaad..cf2451e9d14ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bcrypt==4.3.0 bleak-retry-connector==4.4.3 bleak==1.0.1 bluetooth-adapters==2.1.0 -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 diff --git a/requirements_all.txt b/requirements_all.txt index 7e0de86a8d873..929ed6c53932d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -655,7 +655,7 @@ bluemaestro-ble==0.4.1 bluetooth-adapters==2.1.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24aeff1a21a8b..e6c68c20fc9ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -586,7 +586,7 @@ bluemaestro-ble==0.4.1 bluetooth-adapters==2.1.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble From bbb67db3546557d2464128a613a6ea52756e77b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 14 Sep 2025 19:47:01 +0200 Subject: [PATCH 72/90] Add proper error handling for /actions endpoint for miele (#152290) --- homeassistant/components/miele/coordinator.py | 20 +++++++++++-- tests/components/miele/test_init.py | 28 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 98f5c9f8b1cfd..b3eb1185bd1af 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -2,12 +2,13 @@ from __future__ import annotations -import asyncio.timeouts +import asyncio from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging +from aiohttp import ClientResponseError from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.config_entries import ConfigEntry @@ -66,7 +67,22 @@ async def _async_update_data(self) -> MieleCoordinatorData: self.devices = devices actions = {} for device_id in devices: - actions_json = await self.api.get_actions(device_id) + try: + actions_json = await self.api.get_actions(device_id) + except ClientResponseError as err: + _LOGGER.debug( + "Error fetching actions for device %s: Status: %s, Message: %s", + device_id, + err.status, + err.message, + ) + actions_json = {} + except TimeoutError: + _LOGGER.debug( + "Timeout fetching actions for device %s", + device_id, + ) + actions_json = {} actions[device_id] = MieleAction(actions_json) return MieleCoordinatorData(devices=devices, actions=actions) diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index cdf1a39b4213c..0448096a1157e 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -5,7 +5,7 @@ import time from unittest.mock import MagicMock -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from freezegun.api import FrozenDateTimeFactory from pymiele import OAUTH2_TOKEN import pytest @@ -210,3 +210,29 @@ async def test_setup_all_platforms( # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" assert hass.states.get("sensor.oven_temperature_2").state == "175.0" + + +@pytest.mark.parametrize( + "side_effect", + [ + ClientResponseError("test", "Test"), + TimeoutError, + ], + ids=[ + "ClientResponseError", + "TimeoutError", + ], +) +async def test_load_entry_with_action_error( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test load with error from actions endpoint.""" + mock_miele_client.get_actions.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + assert mock_miele_client.get_actions.call_count == 5 From 8728312e87ff53cd66fcef6d7c3c6fe5ca8c4126 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 12:02:11 -0500 Subject: [PATCH 73/90] Bump aiohomekit to 3.2.17 (#152297) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ef4fdadb24c41..e9ea92c78e82d 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.16"], + "requirements": ["aiohomekit==3.2.17"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 929ed6c53932d..bbfc8b05ad905 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -271,7 +271,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6c68c20fc9ee..aa869b5e85028 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -256,7 +256,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From 0b159bdb9c03f92ee464791005bcb7966bf4e271 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 14 Sep 2025 14:07:31 -0700 Subject: [PATCH 74/90] Update authorization server to prefer absolute urls (#152313) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/auth/login_flow.py | 19 +++++++-- tests/components/auth/test_login_flow.py | 46 ++++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 69ae3eb65bd4b..675c2d10fea37 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -92,7 +92,11 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.network import is_cloud_connection +from homeassistant.helpers.network import ( + NoURLAvailableError, + get_url, + is_cloud_connection, +) from homeassistant.util.network import is_local from . import indieauth @@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Return the well known OAuth2 authorization info.""" + hass = request.app[KEY_HASS] + # Some applications require absolute urls, so we prefer using the + # current requests url if possible, with fallback to a relative url. + try: + url_prefix = get_url(hass, require_current_request=True) + except NoURLAvailableError: + url_prefix = "" return self.json( { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{url_prefix}/auth/authorize", + "token_endpoint": f"{url_prefix}/auth/token", + "revocation_endpoint": f"{url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": ( "https://developers.home-assistant.io/docs/auth_api" diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index af9a2cf62f1a8..f7d20687c926f 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from . import BASE_CONFIG, async_setup_auth @@ -371,19 +372,54 @@ async def test_login_exist_user_ip_changes( assert response == {"message": "IP address changed"} +@pytest.mark.usefixtures("current_request_with_host") # Has example.com host +@pytest.mark.parametrize( + ("config", "expected_url_prefix"), + [ + ( + { + "internal_url": "http://192.168.1.100:8123", + # Current request matches external url + "external_url": "https://example.com", + }, + "https://example.com", + ), + ( + { + # Current request matches internal url + "internal_url": "https://example.com", + "external_url": "https://other.com", + }, + "https://example.com", + ), + ( + { + # Current request does not match either url + "internal_url": "https://other.com", + "external_url": "https://again.com", + }, + "", + ), + ], + ids=["external_url", "internal_url", "no_match"], +) async def test_well_known_auth_info( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + config: dict[str, str], + expected_url_prefix: str, ) -> None: - """Test logging in and the ip address changes results in an rejection.""" + """Test the well-known OAuth authorization server endpoint with different URL configurations.""" + await async_process_ha_core_config(hass, config) client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.get( "/.well-known/oauth-authorization-server", ) assert resp.status == 200 assert await resp.json() == { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", + "token_endpoint": f"{expected_url_prefix}/auth/token", + "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": "https://developers.home-assistant.io/docs/auth_api", } From 757aec1c6b26414811b857899b0390addbe4b3ef Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Mon, 15 Sep 2025 11:14:31 +0200 Subject: [PATCH 75/90] Bump imeon_inverter_api to 0.4.0 (#152351) Co-authored-by: TheBushBoy --- homeassistant/components/imeon_inverter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index 837b735124175..ed24d169d6333 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.16"], + "requirements": ["imeon_inverter_api==0.4.0"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/requirements_all.txt b/requirements_all.txt index bbfc8b05ad905..e40e6a9891d76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.16 +imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib imgw_pib==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa869b5e85028..a956ae28415c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1075,7 +1075,7 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.16 +imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib imgw_pib==1.5.4 From 10b186a20de3eab026863ed813b40ea1e36c8cfa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 16 Sep 2025 10:08:08 +0200 Subject: [PATCH 76/90] Bump pylamarzocco to 2.1.0 (#152364) --- .../components/lamarzocco/__init__.py | 41 ++++++------- .../components/lamarzocco/config_flow.py | 13 +++- homeassistant/components/lamarzocco/const.py | 1 + .../components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lamarzocco/__init__.py | 3 + tests/components/lamarzocco/conftest.py | 24 +++++++- .../components/lamarzocco/test_config_flow.py | 17 +++++- tests/components/lamarzocco/test_init.py | 60 +++++++++---------- 10 files changed, 102 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 92184b4ac51c1..15ff16346879c 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -2,6 +2,7 @@ import asyncio import logging +import uuid from packaging import version from pylamarzocco import ( @@ -11,6 +12,7 @@ ) from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.util import InstallationKey, generate_installation_key from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.const import ( @@ -25,7 +27,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_USE_BLUETOOTH, DOMAIN +from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( LaMarzoccoConfigEntry, LaMarzoccoConfigUpdateCoordinator, @@ -60,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]), client=async_create_clientsession(hass), ) @@ -166,45 +169,37 @@ async def async_migrate_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry ) -> bool: """Migrate config entry.""" - if entry.version > 3: + if entry.version > 4: # guard against downgrade from a future version return False - if entry.version == 1: + if entry.version in (1, 2): _LOGGER.error( - "Migration from version 1 is no longer supported, please remove and re-add the integration" + "Migration from version 1 or 2 is no longer supported, please remove and re-add the integration" ) return False - if entry.version == 2: + if entry.version == 3: + installation_key = generate_installation_key(str(uuid.uuid4()).lower()) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + installation_key=installation_key, ) try: - things = await cloud_client.list_things() + await cloud_client.async_register_client() except (AuthFail, RequestNotSuccessful) as exc: _LOGGER.error("Migration failed with error %s", exc) return False - v3_data = { - CONF_USERNAME: entry.data[CONF_USERNAME], - CONF_PASSWORD: entry.data[CONF_PASSWORD], - CONF_TOKEN: next( - ( - thing.ble_auth_token - for thing in things - if thing.serial_number == entry.unique_id - ), - None, - ), - } - if CONF_MAC in entry.data: - v3_data[CONF_MAC] = entry.data[CONF_MAC] + hass.config_entries.async_update_entry( entry, - data=v3_data, - version=3, + data={ + **entry.data, + CONF_INSTALLATION_KEY: installation_key.to_json(), + }, + version=4, ) - _LOGGER.debug("Migrated La Marzocco config entry to version 2") + _LOGGER.debug("Migrated La Marzocco config entry to version 4") return True diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index fb968a0b4af9d..7f08ac9a48edf 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -5,11 +5,13 @@ from collections.abc import Mapping import logging from typing import Any +import uuid from aiohttp import ClientSession from pylamarzocco import LaMarzoccoCloudClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import Thing +from pylamarzocco.util import InstallationKey, generate_installation_key import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -45,7 +47,7 @@ ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_BLUETOOTH, DOMAIN +from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" @@ -57,9 +59,10 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for La Marzocco.""" - VERSION = 3 + VERSION = 4 _client: ClientSession + _installation_key: InstallationKey def __init__(self) -> None: """Initialize the config flow.""" @@ -84,12 +87,17 @@ async def async_step_user( } self._client = async_create_clientsession(self.hass) + self._installation_key = generate_installation_key( + str(uuid.uuid4()).lower() + ) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], client=self._client, + installation_key=self._installation_key, ) try: + await cloud_client.async_register_client() things = await cloud_client.list_things() except AuthFail: _LOGGER.debug("Server rejected login credentials") @@ -184,6 +192,7 @@ async def async_step_machine_selection( title=selected_device.name, data={ **self._config, + CONF_INSTALLATION_KEY: self._installation_key.to_json(), CONF_TOKEN: self._things[serial_number].ble_auth_token, }, ) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py index 57db84f94da15..680557d85f182 100644 --- a/homeassistant/components/lamarzocco/const.py +++ b/homeassistant/components/lamarzocco/const.py @@ -5,3 +5,4 @@ DOMAIN: Final = "lamarzocco" CONF_USE_BLUETOOTH: Final = "use_bluetooth" +CONF_INSTALLATION_KEY: Final = "installation_key" diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 3c070769b5b09..ec55a7e8c2b1d 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.11"] + "requirements": ["pylamarzocco==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e40e6a9891d76..b7e231f3be219 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2103,7 +2103,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.11 +pylamarzocco==2.1.0 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a956ae28415c2..5fd585f3e03a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1748,7 +1748,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.11 +pylamarzocco==2.1.0 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index 80493aa83c97e..55335f720c3ae 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -54,3 +54,6 @@ def get_bluetooth_service_info(model: ModelName, serial: str) -> BluetoothServic service_uuids=[], source="local", ) + + +MOCK_INSTALLATION_KEY = '{"secret": "K9ZW2vlMSb3QXmhySx4pxAbTHujWj3VZ01Jn3D/sO98=", "private_key": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8iotE8El786F6kHuEL8GyYhjDB7oo06vNhQwtewF37yhRANCAAQCLb9lHskiavvfkI4H2B+WsdkusfgBBFuFNRrGV8bqPMra1TK5myb/ecdZfHJBBJrcbdt90QMDmXQm5L3muXXe", "installation_id": "4e966f3f-2abc-49c4-a362-3cd3346f1a87"}' diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index ad1378a6dc197..7907a1d6a7ed6 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -12,13 +12,14 @@ ThingSettings, ThingStatistics, ) +from pylamarzocco.util import InstallationKey import pytest -from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.components.lamarzocco.const import CONF_INSTALLATION_KEY, DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SERIAL_DICT, USER_INPUT, async_init_integration +from . import MOCK_INSTALLATION_KEY, SERIAL_DICT, USER_INPUT, async_init_integration from tests.common import MockConfigEntry, load_json_object_fixture @@ -31,11 +32,12 @@ def mock_config_entry( return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - version=3, + version=4, data=USER_INPUT | { CONF_ADDRESS: "000000000000", CONF_TOKEN: "token", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, }, unique_id=mock_lamarzocco.serial_number, ) @@ -51,6 +53,22 @@ async def init_integration( return mock_config_entry +@pytest.fixture(autouse=True) +def mock_generate_installation_key() -> Generator[MagicMock]: + """Return a mocked generate_installation_key.""" + with ( + patch( + "homeassistant.components.lamarzocco.generate_installation_key", + return_value=InstallationKey.from_json(MOCK_INSTALLATION_KEY), + ) as mock_generate, + patch( + "homeassistant.components.lamarzocco.config_flow.generate_installation_key", + new=mock_generate, + ), + ): + yield mock_generate + + @pytest.fixture def device_fixture() -> ModelName: """Return the device fixture for a specific device.""" diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index e50707f71afda..5d0a514b79320 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -9,7 +9,11 @@ import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE -from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN +from homeassistant.components.lamarzocco.const import ( + CONF_INSTALLATION_KEY, + CONF_USE_BLUETOOTH, + DOMAIN, +) from homeassistant.config_entries import ( SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -23,7 +27,12 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import USER_INPUT, async_init_integration, get_bluetooth_service_info +from . import ( + MOCK_INSTALLATION_KEY, + USER_INPUT, + async_init_integration, + get_bluetooth_service_info, +) from tests.common import MockConfigEntry @@ -68,6 +77,7 @@ async def __do_sucessful_machine_selection_step( assert result["data"] == { **USER_INPUT, CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } assert result["result"].unique_id == "GS012345" @@ -344,6 +354,7 @@ async def test_bluetooth_discovery( **USER_INPUT, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: "dummyToken", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } @@ -407,6 +418,7 @@ async def test_bluetooth_discovery_errors( **USER_INPUT, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } @@ -438,6 +450,7 @@ async def test_dhcp_discovery( **USER_INPUT, CONF_ADDRESS: "aabbccddeeff", CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 1e56e540e2a67..e6bf4a0af62e5 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -8,15 +8,11 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE -from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.components.lamarzocco.const import CONF_INSTALLATION_KEY, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_ADDRESS, - CONF_HOST, CONF_MAC, - CONF_MODEL, - CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, ) @@ -27,7 +23,12 @@ issue_registry as ir, ) -from . import USER_INPUT, async_init_integration, get_bluetooth_service_info +from . import ( + MOCK_INSTALLATION_KEY, + USER_INPUT, + async_init_integration, + get_bluetooth_service_info, +) from tests.common import MockConfigEntry @@ -129,66 +130,65 @@ async def test_v1_migration_fails( assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR -async def test_v2_migration( +async def test_v4_migration( hass: HomeAssistant, mock_lamarzocco: MagicMock, ) -> None: - """Test v2 -> v3 Migration.""" + """Test v3 -> v4 Migration.""" - entry_v2 = MockConfigEntry( + entry_v3 = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, - CONF_HOST: "192.168.1.24", - CONF_NAME: "La Marzocco", - CONF_MODEL: ModelName.GS3_MP.value, - CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", }, ) - entry_v2.add_to_hass(hass) + entry_v3.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry_v2.entry_id) - assert entry_v2.state is ConfigEntryState.LOADED - assert entry_v2.version == 3 - assert dict(entry_v2.data) == { + assert await hass.config_entries.async_setup(entry_v3.entry_id) + assert entry_v3.state is ConfigEntryState.LOADED + assert entry_v3.version == 4 + assert dict(entry_v3.data) == { **USER_INPUT, - CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_TOKEN: None, + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } async def test_migration_errors( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_cloud_client: MagicMock, mock_lamarzocco: MagicMock, ) -> None: """Test errors during migration.""" - mock_cloud_client.list_things.side_effect = RequestNotSuccessful("Error") + mock_cloud_client.async_register_client.side_effect = RequestNotSuccessful("Error") - entry_v2 = MockConfigEntry( + entry_v3 = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, - CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", }, ) - entry_v2.add_to_hass(hass) + entry_v3.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry_v2.entry_id) - assert entry_v2.state is ConfigEntryState.MIGRATION_ERROR + assert not await hass.config_entries.async_setup(entry_v3.entry_id) + assert entry_v3.state is ConfigEntryState.MIGRATION_ERROR async def test_config_flow_entry_migration_downgrade( hass: HomeAssistant, ) -> None: """Test that config entry fails setup if the version is from the future.""" - entry = MockConfigEntry(domain=DOMAIN, version=4) + entry = MockConfigEntry(domain=DOMAIN, version=5) entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(entry.entry_id) From 9cd940b7df857b36e02a682cf6ac082b389e13d5 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 16 Sep 2025 12:15:57 +0200 Subject: [PATCH 77/90] Add La Marzocco specific client headers (#152419) --- homeassistant/components/lamarzocco/__init__.py | 17 ++++++++++++++++- .../components/lamarzocco/config_flow.py | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 15ff16346879c..96d4f4c61ac4c 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -4,6 +4,7 @@ import logging import uuid +from aiohttp import ClientSession from packaging import version from pylamarzocco import ( LaMarzoccoBluetoothClient, @@ -21,6 +22,7 @@ CONF_TOKEN, CONF_USERNAME, Platform, + __version__, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -63,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]), - client=async_create_clientsession(hass), + client=create_client_session(hass), ) try: @@ -185,6 +187,7 @@ async def async_migrate_entry( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], installation_key=installation_key, + client=create_client_session(hass), ) try: await cloud_client.async_register_client() @@ -203,3 +206,15 @@ async def async_migrate_entry( _LOGGER.debug("Migrated La Marzocco config entry to version 4") return True + + +def create_client_session(hass: HomeAssistant) -> ClientSession: + """Create a ClientSession with La Marzocco specific headers.""" + + return async_create_clientsession( + hass, + headers={ + "X-Client": "HOME_ASSISTANT", + "X-Client-Build": __version__, + }, + ) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 7f08ac9a48edf..ab99fbbc63fe0 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -35,7 +35,6 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -47,6 +46,7 @@ ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import create_client_session from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry @@ -86,7 +86,7 @@ async def async_step_user( **user_input, } - self._client = async_create_clientsession(self.hass) + self._client = create_client_session(self.hass) self._installation_key = generate_installation_key( str(uuid.uuid4()).lower() ) From 950e758b62b460ec40cf95b3461c4d465044e3bc Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 16 Sep 2025 19:39:39 +0200 Subject: [PATCH 78/90] Fix KNX UI schema missing DPT (#152430) --- .../knx/storage/entity_store_schema.py | 22 +++++---- .../knx/snapshots/test_websocket.ambr | 48 +++++++++++++++++-- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 21252e35f3a7a..934008132a897 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -118,27 +118,31 @@ vol.Schema( { "section_binary_control": KNXSectionFlat(), - vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), "section_stop_control": KNXSectionFlat(), - vol.Optional(CONF_GA_STOP): GASelector(state=False), - vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"), + vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"), "section_position_control": KNXSectionFlat(collapsible=True), - vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), - vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector( + state=False, valid_dpt="5.001" + ), + vol.Optional(CONF_GA_POSITION_STATE): GASelector( + write=False, valid_dpt="5.001" + ), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), "section_tilt_control": KNXSectionFlat(collapsible=True), - vol.Optional(CONF_GA_ANGLE): GASelector(), + vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), "section_travel_time": KNXSectionFlat(), - vol.Optional( + vol.Required( CoverConf.TRAVELLING_TIME_UP, default=25 ): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=1000, step=0.1, unit_of_measurement="s" ) ), - vol.Optional( + vol.Required( CoverConf.TRAVELLING_TIME_DOWN, default=25 ): selector.NumberSelector( selector.NumberSelectorConfig( @@ -310,7 +314,7 @@ class LightColorMode(StrEnum): SWITCH_KNX_SCHEMA = vol.Schema( { "section_switch": KNXSectionFlat(), - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"), vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(), vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 6dc651195aeb5..388c68e0d3f4f 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -111,6 +111,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -140,6 +146,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -153,6 +165,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -172,6 +190,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -187,6 +211,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': False, }), 'required': False, @@ -216,6 +246,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -242,8 +278,7 @@ dict({ 'default': 25, 'name': 'travelling_time_up', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -258,8 +293,7 @@ dict({ 'default': 25, 'name': 'travelling_time_down', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -746,6 +780,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': True, }), From b37237d24bff9d74bbc54b7389a923c751ede9a8 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 16 Sep 2025 18:32:13 +0200 Subject: [PATCH 79/90] Bump pyemoncms to 0.1.3 (#152436) --- homeassistant/components/emoncms/manifest.json | 2 +- homeassistant/components/emoncms_history/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index bc86e6e9babdb..d21da45397620 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", "iot_class": "local_polling", - "requirements": ["pyemoncms==0.1.2"] + "requirements": ["pyemoncms==0.1.3"] } diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json index 3c8c445b766ea..29a061f9229e5 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/emoncms_history", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["pyemoncms==0.1.2"] + "requirements": ["pyemoncms==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7e231f3be219..4f1c2d3c238e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyegps==0.2.5 # homeassistant.components.emoncms # homeassistant.components.emoncms_history -pyemoncms==0.1.2 +pyemoncms==0.1.3 # homeassistant.components.enphase_envoy pyenphase==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fd585f3e03a0..5cedd3175c100 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyegps==0.2.5 # homeassistant.components.emoncms # homeassistant.components.emoncms_history -pyemoncms==0.1.2 +pyemoncms==0.1.3 # homeassistant.components.enphase_envoy pyenphase==2.3.0 From 8eee53036a04a8c666b74139a74cf894df70eecf Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 17 Sep 2025 08:39:28 -0400 Subject: [PATCH 80/90] Fix Sonos set_volume float precision issue (#152493) --- homeassistant/components/sonos/media_player.py | 2 +- tests/components/sonos/test_media_player.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0b30c820da393..984996302d99b 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -410,7 +410,7 @@ def volume_down(self) -> None: @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.soco.volume = int(volume * 100) + self.soco.volume = int(round(volume * 100)) @soco_error(UPNP_ERRORS_TO_IGNORE) def set_shuffle(self, shuffle: bool) -> None: diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 41b18750fd4c1..b7889edf416c3 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1101,11 +1101,11 @@ async def test_volume( await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.30}, + {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.57}, blocking=True, ) # SoCo uses 0..100 for its range. - assert soco.volume == 30 + assert soco.volume == 57 @pytest.mark.parametrize( From cf907ae1968c89ed94336e4fc46678b02d6ba2f5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 18 Sep 2025 04:29:27 -0700 Subject: [PATCH 81/90] Bump opower to 0.15.5 (#152531) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index dc69c33cd5d46..251734107abcf 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.15.4"] + "requirements": ["opower==0.15.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f1c2d3c238e1..cee455a5966be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1629,7 +1629,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.4 +opower==0.15.5 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5cedd3175c100..81337c500d41d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1385,7 +1385,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.4 +opower==0.15.5 # homeassistant.components.oralb oralb-ble==0.17.6 From c745ee18eb597b7e3a3f97a26f41456035af6dcf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 14 Sep 2025 17:51:47 +0200 Subject: [PATCH 82/90] Bump holidays to 0.80 (#152306) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5ea0d217f141f..40c27762f0081 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.79", "babel==2.15.0"] + "requirements": ["holidays==0.80", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 0e336632b2ebf..8b917d5d8bd35 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.79"] + "requirements": ["holidays==0.80"] } diff --git a/requirements_all.txt b/requirements_all.txt index cee455a5966be..dd36311d00621 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1175,7 +1175,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81337c500d41d..6bd86f3cb5283 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1024,7 +1024,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 From be83416c72a60d71d69e09bfc30479f7bc7c74a2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 18 Sep 2025 20:35:21 +0200 Subject: [PATCH 83/90] Bump holidays to 0.81 (#152569) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 40c27762f0081..82e83275b6b67 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.80", "babel==2.15.0"] + "requirements": ["holidays==0.81", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 8b917d5d8bd35..c7a97ffb392df 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.80"] + "requirements": ["holidays==0.81"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd36311d00621..c29a48fb708bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1175,7 +1175,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.80 +holidays==0.81 # homeassistant.components.frontend home-assistant-frontend==20250903.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bd86f3cb5283..979d206beab03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1024,7 +1024,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.80 +holidays==0.81 # homeassistant.components.frontend home-assistant-frontend==20250903.5 From 6dc78707792c3aee6a4a9608d9d12627b536346b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 19 Sep 2025 20:07:56 +0000 Subject: [PATCH 84/90] Bump version to 2025.9.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 217247dedfcc6..608bf5d782c53 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index ef4603faa8186..fcb7a8a004b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.9.3" +version = "2025.9.4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 3f3aaa2815f3f4cdc511e5f4549d621437905c7f Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Wed, 3 Sep 2025 12:16:00 +0200 Subject: [PATCH 85/90] Bump asusrouter to 1.21.0 (#151607) --- homeassistant/components/asuswrt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 0fcc6f2d3d0c8..6273c77ca783a 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.20.1"] + "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c29a48fb708bc..d3e897c62fb1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -528,7 +528,7 @@ arris-tg2492lg==2.2.0 asmog==0.0.6 # homeassistant.components.asuswrt -asusrouter==1.20.1 +asusrouter==1.21.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 979d206beab03..0c89951f412f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -492,7 +492,7 @@ aranet4==2.5.1 arcam-fmj==1.8.2 # homeassistant.components.asuswrt -asusrouter==1.20.1 +asusrouter==1.21.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 054a5d751aab48da6f0ee17a8b4e5d1c157132ad Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 20 Sep 2025 13:24:30 +0200 Subject: [PATCH 86/90] Organize order MQTT subentry (test) globals and translation strings (#152576) --- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/strings.json | 10 +- tests/components/mqtt/common.py | 119 +++++++++---------- 3 files changed, 67 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index dc208610b8cb8..366f989b2925b 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1209,7 +1209,6 @@ class PlatformField: default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), ), }, - Platform.NOTIFY.value: {}, Platform.LIGHT.value: { CONF_SCHEMA: PlatformField( selector=LIGHT_SCHEMA_SELECTOR, @@ -1225,6 +1224,7 @@ class PlatformField: ), }, Platform.LOCK.value: {}, + Platform.NOTIFY.value: {}, Platform.SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 2075345e038e1..3eadb2f5917a1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -321,11 +321,11 @@ "code_arm_required": "Code arm required", "code_disarm_required": "Code disarm required", "code_trigger_required": "Code trigger required", + "color_temp_template": "Color temperature template", "command_template": "Command template", "command_topic": "Command topic", "command_off_template": "Command \"off\" template", "command_on_template": "Command \"on\" template", - "color_temp_template": "Color temperature template", "force_update": "Force update", "green_template": "Green template", "last_reset_value_template": "Last reset value template", @@ -358,11 +358,11 @@ "code_arm_required": "If set, the code is required to arm the alarm. If not set, the code is not validated.", "code_disarm_required": "If set, the code is required to disarm the alarm. If not set, the code is not validated.", "code_trigger_required": "If set, the code is required to manually trigger the alarm. If not set, the code is not validated.", + "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)", "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", - "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", @@ -1261,6 +1261,12 @@ "diagnostic": "Diagnostic" } }, + "image_processing_mode": { + "options": { + "image_data": "Image data is received", + "image_url": "Image URL is received" + } + }, "light_schema": { "options": { "basic": "Default schema", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 417b1465aa317..9c05fee8fd9d9 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -356,6 +356,51 @@ "speed_range_min": 1, }, } +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", + "entity_category": None, + "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", + "brightness_scale": 255, + "max_kelvin": 6535, + "min_kelvin": 2000, + "white_scale": 255, + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_LOCK_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f100": { + "platform": "lock", + "name": "Lock", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "code_format": "^\\d{4}$", + "payload_open": "OPEN", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_reset": "None", + "state_jammed": "JAMMED", + "state_locked": "LOCKED", + "state_locking": "LOCKING", + "state_unlocked": "UNLOCKED", + "state_unlocking": "UNLOCKING", + "retain": False, + "entity_category": None, + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f100", + "optimistic": True, + }, +} MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", @@ -387,32 +432,13 @@ "retain": False, }, } - -MOCK_SUBENTRY_LOCK_COMPONENT = { - "3faf1318016c46c5aea26707eeb6f100": { - "platform": "lock", - "name": "Lock", - "command_topic": "test-topic", - "state_topic": "test-topic", - "command_template": "{{ value }}", - "value_template": "{{ value_json.value }}", - "code_format": "^\\d{4}$", - "payload_open": "OPEN", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "payload_reset": "None", - "state_jammed": "JAMMED", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - "retain": False, - "entity_category": None, - "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f100", - "optimistic": True, +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "command_topic": "bad#topic", }, } - MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", @@ -464,35 +490,6 @@ }, } -MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { - "8131babc5e8d4f44b82e0761d39091a2": { - "platform": "light", - "name": "Basic light", - "on_command_type": "last", - "optimistic": True, - "payload_off": "OFF", - "payload_on": "ON", - "command_topic": "test-topic", - "entity_category": None, - "schema": "basic", - "state_topic": "test-topic", - "color_temp_kelvin": True, - "state_value_template": "{{ value_json.value }}", - "brightness_scale": 255, - "max_kelvin": 6535, - "min_kelvin": 2000, - "white_scale": 255, - "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", - }, -} -MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { - "b10b531e15244425a74bb0abb1e9d2c6": { - "platform": "notify", - "name": "Test", - "command_topic": "bad#topic", - }, -} - MOCK_SUBENTRY_AVAILABILITY_DATA = { "availability": { "availability_topic": "test/availability", @@ -556,14 +553,6 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_FAN_COMPONENT, } -MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { - "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, - "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, -} -MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { - "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, - "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, -} MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, @@ -572,6 +561,14 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LOCK_COMPONENT, } +MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, +} +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, From bfc9616abf8d4adeeeeaa5d22ae995beeb11b0fb Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 20 Sep 2025 04:50:31 -0700 Subject: [PATCH 87/90] Deprecate google_generative_ai_conversation.generate_content (#152644) --- .../__init__.py | 16 ++++++++++++++++ .../strings.json | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c6a07a9333107..82561d9f75e94 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -29,6 +29,7 @@ config_validation as cv, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.typing import ConfigType @@ -70,6 +71,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" + LOGGER.warning( + "Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. " + "Please use the 'ai_task.generate_data' action instead", + DOMAIN, + SERVICE_GENERATE_CONTENT, + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_generate_content", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_generate_content", + ) prompt_parts = [call.data[CONF_PROMPT]] diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 43008332e6879..cc94d7de8fe83 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -150,10 +150,16 @@ } } }, + "issues": { + "deprecated_generate_content": { + "title": "Deprecated 'generate_content' action", + "description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead" + } + }, "services": { "generate_content": { - "name": "Generate content", - "description": "Generate content from a prompt consisting of text and optionally images", + "name": "Generate content (deprecated)", + "description": "Generate content from a prompt consisting of text and optionally images (deprecated)", "fields": { "prompt": { "name": "Prompt", From 9531ae10f28e8d22aca3ed68a8ab6602eaa7a85e Mon Sep 17 00:00:00 2001 From: Stephan van Rooij <1292510+svrooij@users.noreply.github.com> Date: Sat, 20 Sep 2025 14:08:53 +0200 Subject: [PATCH 88/90] Remove volvooncall (#150725) Co-authored-by: G Johansson --- CODEOWNERS | 4 +- .../components/volvooncall/__init__.py | 85 +++------ .../components/volvooncall/binary_sensor.py | 79 -------- .../components/volvooncall/config_flow.py | 116 +----------- homeassistant/components/volvooncall/const.py | 65 +------ .../components/volvooncall/coordinator.py | 40 ---- .../components/volvooncall/device_tracker.py | 72 ------- .../components/volvooncall/entity.py | 88 --------- .../components/volvooncall/errors.py | 7 - homeassistant/components/volvooncall/lock.py | 80 -------- .../components/volvooncall/manifest.json | 5 +- .../components/volvooncall/models.py | 100 ---------- .../components/volvooncall/sensor.py | 72 ------- .../components/volvooncall/strings.json | 21 +-- .../components/volvooncall/switch.py | 78 -------- requirements_all.txt | 3 - requirements_test_all.txt | 3 - .../volvooncall/test_config_flow.py | 177 ++---------------- tests/components/volvooncall/test_init.py | 76 ++++++++ 19 files changed, 138 insertions(+), 1033 deletions(-) delete mode 100644 homeassistant/components/volvooncall/binary_sensor.py delete mode 100644 homeassistant/components/volvooncall/coordinator.py delete mode 100644 homeassistant/components/volvooncall/device_tracker.py delete mode 100644 homeassistant/components/volvooncall/entity.py delete mode 100644 homeassistant/components/volvooncall/errors.py delete mode 100644 homeassistant/components/volvooncall/lock.py delete mode 100644 homeassistant/components/volvooncall/models.py delete mode 100644 homeassistant/components/volvooncall/sensor.py delete mode 100644 homeassistant/components/volvooncall/switch.py create mode 100644 tests/components/volvooncall/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 543ef798b1c52..a0f5171dd4955 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1729,8 +1729,8 @@ build.json @home-assistant/supervisor /tests/components/volumio/ @OnFreund /homeassistant/components/volvo/ @thomasddn /tests/components/volvo/ @thomasddn -/homeassistant/components/volvooncall/ @molobrakos -/tests/components/volvooncall/ @molobrakos +/homeassistant/components/volvooncall/ @molobrakos @svrooij +/tests/components/volvooncall/ @molobrakos @svrooij /homeassistant/components/wake_on_lan/ @ntilley905 /tests/components/wake_on_lan/ @ntilley905 /homeassistant/components/wake_word/ @home-assistant/core @synesthesiam diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 1a53f9a5dc469..6542f34b487da 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,71 +1,46 @@ -"""Support for Volvo On Call.""" +"""The Volvo On Call integration.""" -from volvooncall import Connection +from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_REGION, - CONF_UNIT_SYSTEM, - CONF_USERNAME, -) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import issue_registry as ir -from .const import ( - CONF_SCANDINAVIAN_MILES, - DOMAIN, - PLATFORMS, - UNIT_SYSTEM_METRIC, - UNIT_SYSTEM_SCANDINAVIAN_MILES, -) -from .coordinator import VolvoUpdateCoordinator -from .models import VolvoData +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Volvo On Call component from a ConfigEntry.""" - - # added CONF_UNIT_SYSTEM / deprecated CONF_SCANDINAVIAN_MILES in 2022.10 to support imperial units - if CONF_UNIT_SYSTEM not in entry.data: - new_conf = {**entry.data} - - scandinavian_miles: bool = entry.data[CONF_SCANDINAVIAN_MILES] - - new_conf[CONF_UNIT_SYSTEM] = ( - UNIT_SYSTEM_SCANDINAVIAN_MILES if scandinavian_miles else UNIT_SYSTEM_METRIC - ) - - hass.config_entries.async_update_entry(entry, data=new_conf) - - session = async_get_clientsession(hass) - - connection = Connection( - session=session, - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - service_url=None, - region=entry.data[CONF_REGION], + """Set up Volvo On Call integration.""" + + # Create repair issue pointing to the new volvo integration + ir.async_create_issue( + hass, + DOMAIN, + "volvooncall_deprecated", + breaks_in_ha_version="2026.3", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="volvooncall_deprecated", ) - hass.data.setdefault(DOMAIN, {}) - - volvo_data = VolvoData(hass, connection, entry) - - coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + # Only delete the repair issue if this is the last config entry for this domain + remaining_entries = [ + config_entry + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ] + + if not remaining_entries: + ir.async_delete_issue( + hass, + DOMAIN, + "volvooncall_deprecated", + ) + + return True diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py deleted file mode 100644 index 2ba8d19e3dbb7..0000000000000 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for VOC.""" - -from __future__ import annotations - -from contextlib import suppress - -import voluptuous as vol -from volvooncall.dashboard import Instrument - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure binary_sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call binary sensor.""" - async_add_entities( - VolvoSensor( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "binary_sensor" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSensor(VolvoEntity, BinarySensorEntity): - """Representation of a Volvo sensor.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - with suppress(vol.Invalid): - self._attr_device_class = DEVICE_CLASSES_SCHEMA( - self.instrument.device_class - ) - - @property - def is_on(self) -> bool | None: - """Fetch from update coordinator.""" - if self.instrument.attr == "is_locked": - return not self.instrument.is_on - return self.instrument.is_on diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index ccb0a7f62e112..e1aa95cb7306b 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -2,127 +2,21 @@ from __future__ import annotations -from collections.abc import Mapping -import logging from typing import Any -import voluptuous as vol -from volvooncall import Connection +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import ( - CONF_PASSWORD, - CONF_REGION, - CONF_UNIT_SYSTEM, - CONF_USERNAME, -) -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import ( - CONF_MUTABLE, - DOMAIN, - UNIT_SYSTEM_IMPERIAL, - UNIT_SYSTEM_METRIC, - UNIT_SYSTEM_SCANDINAVIAN_MILES, -) -from .errors import InvalidAuth -from .models import VolvoData - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN class VolvoOnCallConfigFlow(ConfigFlow, domain=DOMAIN): - """VolvoOnCall config flow.""" + """Handle a config flow for Volvo On Call.""" VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle user step.""" - errors = {} - defaults = { - CONF_USERNAME: "", - CONF_PASSWORD: "", - CONF_REGION: None, - CONF_MUTABLE: True, - CONF_UNIT_SYSTEM: UNIT_SYSTEM_METRIC, - } - - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_USERNAME]) - - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - - try: - await self.is_valid(user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unhandled exception in user step") - errors["base"] = "unknown" - if not errors: - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data_updates=user_input - ) - - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - elif self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() - for key in defaults: - defaults[key] = reauth_entry.data.get(key) - - user_schema = vol.Schema( - { - vol.Required(CONF_USERNAME, default=defaults[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD, default=defaults[CONF_PASSWORD]): str, - vol.Required(CONF_REGION, default=defaults[CONF_REGION]): vol.In( - {"na": "North America", "cn": "China", None: "Rest of world"} - ), - vol.Optional( - CONF_UNIT_SYSTEM, default=defaults[CONF_UNIT_SYSTEM] - ): vol.In( - { - UNIT_SYSTEM_METRIC: "Metric", - UNIT_SYSTEM_SCANDINAVIAN_MILES: ( - "Metric with Scandinavian Miles" - ), - UNIT_SYSTEM_IMPERIAL: "Imperial", - } - ), - vol.Optional(CONF_MUTABLE, default=defaults[CONF_MUTABLE]): bool, - }, - ) - - return self.async_show_form( - step_id="user", data_schema=user_schema, errors=errors - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_user() - - async def is_valid(self, user_input): - """Check for user input errors.""" - - session = async_get_clientsession(self.hass) - - region: str | None = user_input.get(CONF_REGION) - - connection = Connection( - session=session, - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - service_url=None, - region=region, - ) - - test_volvo_data = VolvoData(self.hass, connection, user_input) + """Handle the initial step.""" - await test_volvo_data.auth_is_valid() + return self.async_abort(reason="deprecated") diff --git a/homeassistant/components/volvooncall/const.py b/homeassistant/components/volvooncall/const.py index 4c969669af6d2..e04de08008b27 100644 --- a/homeassistant/components/volvooncall/const.py +++ b/homeassistant/components/volvooncall/const.py @@ -1,66 +1,3 @@ -"""Constants for volvooncall.""" - -from datetime import timedelta +"""Constants for the Volvo On Call integration.""" DOMAIN = "volvooncall" - -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) - -CONF_SERVICE_URL = "service_url" -CONF_SCANDINAVIAN_MILES = "scandinavian_miles" -CONF_MUTABLE = "mutable" - -UNIT_SYSTEM_SCANDINAVIAN_MILES = "scandinavian_miles" -UNIT_SYSTEM_METRIC = "metric" -UNIT_SYSTEM_IMPERIAL = "imperial" - -PLATFORMS = { - "sensor": "sensor", - "binary_sensor": "binary_sensor", - "lock": "lock", - "device_tracker": "device_tracker", - "switch": "switch", -} - -RESOURCES = [ - "position", - "lock", - "heater", - "odometer", - "trip_meter1", - "trip_meter2", - "average_speed", - "fuel_amount", - "fuel_amount_level", - "average_fuel_consumption", - "distance_to_empty", - "washer_fluid_level", - "brake_fluid", - "service_warning_status", - "bulb_failures", - "battery_range", - "battery_level", - "time_to_fully_charged", - "battery_charge_status", - "engine_start", - "last_trip", - "is_engine_running", - "doors_hood_open", - "doors_tailgate_open", - "doors_front_left_door_open", - "doors_front_right_door_open", - "doors_rear_left_door_open", - "doors_rear_right_door_open", - "windows_front_left_window_open", - "windows_front_right_window_open", - "windows_rear_left_window_open", - "windows_rear_right_window_open", - "tyre_pressure_front_left_tyre_pressure", - "tyre_pressure_front_right_tyre_pressure", - "tyre_pressure_rear_left_tyre_pressure", - "tyre_pressure_rear_right_tyre_pressure", - "any_door_open", - "any_window_open", -] - -VOLVO_DISCOVERY_NEW = "volvo_discovery_new" diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py deleted file mode 100644 index 2c3e2ba365f69..0000000000000 --- a/homeassistant/components/volvooncall/coordinator.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Support for Volvo On Call.""" - -import asyncio -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DEFAULT_UPDATE_INTERVAL -from .models import VolvoData - -_LOGGER = logging.getLogger(__name__) - - -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): - """Volvo coordinator.""" - - config_entry: ConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData - ) -> None: - """Initialize the data update coordinator.""" - - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="volvooncall", - update_interval=DEFAULT_UPDATE_INTERVAL, - ) - - self.volvo_data = volvo_data - - async def _async_update_data(self) -> None: - """Fetch data from API endpoint.""" - - async with asyncio.timeout(10): - await self.volvo_data.update() diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py deleted file mode 100644 index 018acb02d49cc..0000000000000 --- a/homeassistant/components/volvooncall/device_tracker.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Support for tracking a Volvo.""" - -from __future__ import annotations - -from volvooncall.dashboard import Instrument - -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure device_trackers from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call device tracker.""" - async_add_entities( - VolvoTrackerEntity( - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - coordinator, - ) - for instrument in instruments - if instrument.component == "device_tracker" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoTrackerEntity(VolvoEntity, TrackerEntity): - """A tracked Volvo vehicle.""" - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - latitude, _ = self._get_pos() - return latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - _, longitude = self._get_pos() - return longitude - - def _get_pos(self) -> tuple[float, float]: - volvo_data = self.coordinator.volvo_data - instrument = volvo_data.instrument( - self.vin, self.component, self.attribute, self.slug_attr - ) - - latitude, longitude, _, _, _ = instrument.state - - return (float(latitude), float(longitude)) diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py deleted file mode 100644 index 5a1194e8b1ab4..0000000000000 --- a/homeassistant/components/volvooncall/entity.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Support for Volvo On Call.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import VolvoUpdateCoordinator - - -class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): - """Base class for all VOC entities.""" - - def __init__( - self, - vin: str, - component: str, - attribute: str, - slug_attr: str, - coordinator: VolvoUpdateCoordinator, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - self.vin = vin - self.component = component - self.attribute = attribute - self.slug_attr = slug_attr - - @property - def instrument(self): - """Return corresponding instrument.""" - return self.coordinator.volvo_data.instrument( - self.vin, self.component, self.attribute, self.slug_attr - ) - - @property - def icon(self): - """Return the icon.""" - return self.instrument.icon - - @property - def vehicle(self): - """Return vehicle.""" - return self.instrument.vehicle - - @property - def _entity_name(self): - return self.instrument.name - - @property - def _vehicle_name(self): - return self.coordinator.volvo_data.vehicle_name(self.vehicle) - - @property - def name(self): - """Return full name of the entity.""" - return f"{self._vehicle_name} {self._entity_name}" - - @property - def assumed_state(self) -> bool: - """Return true if unable to access real state of entity.""" - return True - - @property - def device_info(self) -> DeviceInfo: - """Return a inique set of attributes for each vehicle.""" - return DeviceInfo( - identifiers={(DOMAIN, self.vehicle.vin)}, - name=self._vehicle_name, - model=self.vehicle.vehicle_type, - manufacturer="Volvo", - ) - - @property - def extra_state_attributes(self): - """Return device specific state attributes.""" - return dict( - self.instrument.attributes, - model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}", - ) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - slug_override = "" - if self.instrument.slug_override is not None: - slug_override = f"-{self.instrument.slug_override}" - return f"{self.vin}-{self.component}-{self.attribute}{slug_override}" diff --git a/homeassistant/components/volvooncall/errors.py b/homeassistant/components/volvooncall/errors.py deleted file mode 100644 index 3736c5b92906d..0000000000000 --- a/homeassistant/components/volvooncall/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Exceptions specific to volvooncall.""" - -from homeassistant.exceptions import HomeAssistantError - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py deleted file mode 100644 index 75b54e9dbbc94..0000000000000 --- a/homeassistant/components/volvooncall/lock.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Support for Volvo On Call locks.""" - -from __future__ import annotations - -from typing import Any - -from volvooncall.dashboard import Instrument, Lock - -from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure locks from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call lock.""" - async_add_entities( - VolvoLock( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "lock" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoLock(VolvoEntity, LockEntity): - """Represents a car lock.""" - - instrument: Lock - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the lock.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - @property - def is_locked(self) -> bool | None: - """Determine if car is locked.""" - return self.instrument.is_locked - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the car.""" - await self.instrument.lock() - await self.coordinator.async_request_refresh() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the car.""" - await self.instrument.unlock() - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index 89a35ecde1d72..b158cf7ed8010 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -1,10 +1,9 @@ { "domain": "volvooncall", "name": "Volvo On Call", - "codeowners": ["@molobrakos"], + "codeowners": ["@molobrakos", "@svrooij"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/volvooncall", "iot_class": "cloud_polling", - "loggers": ["geopy", "hbmqtt", "volvooncall"], - "requirements": ["volvooncall==0.10.3"] + "quality_scale": "legacy" } diff --git a/homeassistant/components/volvooncall/models.py b/homeassistant/components/volvooncall/models.py deleted file mode 100644 index 159379a908b43..0000000000000 --- a/homeassistant/components/volvooncall/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Support for Volvo On Call.""" - -from aiohttp.client_exceptions import ClientResponseError -from volvooncall import Connection -from volvooncall.dashboard import Instrument - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIT_SYSTEM -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import UpdateFailed - -from .const import ( - CONF_MUTABLE, - PLATFORMS, - UNIT_SYSTEM_IMPERIAL, - UNIT_SYSTEM_SCANDINAVIAN_MILES, - VOLVO_DISCOVERY_NEW, -) -from .errors import InvalidAuth - - -class VolvoData: - """Hold component state.""" - - def __init__( - self, - hass: HomeAssistant, - connection: Connection, - entry: ConfigEntry, - ) -> None: - """Initialize the component state.""" - self.hass = hass - self.vehicles: set[str] = set() - self.instruments: set[Instrument] = set() - self.config_entry = entry - self.connection = connection - - def instrument(self, vin, component, attr, slug_attr): - """Return corresponding instrument.""" - return next( - instrument - for instrument in self.instruments - if instrument.vehicle.vin == vin - and instrument.component == component - and instrument.attr == attr - and instrument.slug_attr == slug_attr - ) - - def vehicle_name(self, vehicle): - """Provide a friendly name for a vehicle.""" - if vehicle.registration_number and vehicle.registration_number != "UNKNOWN": - return vehicle.registration_number - if vehicle.vin: - return vehicle.vin - return "Volvo" - - def discover_vehicle(self, vehicle): - """Load relevant platforms.""" - self.vehicles.add(vehicle.vin) - - dashboard = vehicle.dashboard( - mutable=self.config_entry.data[CONF_MUTABLE], - scandinavian_miles=( - self.config_entry.data[CONF_UNIT_SYSTEM] - == UNIT_SYSTEM_SCANDINAVIAN_MILES - ), - usa_units=( - self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL - ), - ) - - for instrument in ( - instrument - for instrument in dashboard.instruments - if instrument.component in PLATFORMS - ): - self.instruments.add(instrument) - async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument]) - - async def update(self): - """Update status from the online service.""" - try: - await self.connection.update(journal=True) - except ClientResponseError as ex: - if ex.status == 401: - raise ConfigEntryAuthFailed(ex) from ex - raise UpdateFailed(ex) from ex - - for vehicle in self.connection.vehicles: - if vehicle.vin not in self.vehicles: - self.discover_vehicle(vehicle) - - async def auth_is_valid(self): - """Check if provided username/password/region authenticate.""" - try: - await self.connection.get("customeraccounts") - except ClientResponseError as exc: - raise InvalidAuth from exc diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py deleted file mode 100644 index feb7248ccaf75..0000000000000 --- a/homeassistant/components/volvooncall/sensor.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Support for Volvo On Call sensors.""" - -from __future__ import annotations - -from volvooncall.dashboard import Instrument - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call sensor.""" - async_add_entities( - VolvoSensor( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "sensor" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSensor(VolvoEntity, SensorEntity): - """Representation of a Volvo sensor.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - self._update_value_and_unit() - - def _update_value_and_unit(self) -> None: - self._attr_native_value = self.instrument.state - self._attr_native_unit_of_measurement = self.instrument.unit - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_value_and_unit() - self.async_write_ha_state() diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 8524293d6063f..72a406273bd3b 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -2,22 +2,17 @@ "config": { "step": { "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "unit_system": "Unit system", - "mutable": "Allow remote start/lock etc." - } + "description": "Volvo on Call is deprecated, use the Volvo integration" } }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "deprecated": "Volvo On Call has been replaced by the Volvo integration. Please use the Volvo integration instead." + } + }, + "issues": { + "volvooncall_deprecated": { + "title": "Volvo On Call has been replaced", + "description": "The Volvo On Call integration is deprecated and will be removed in 2026.3. Please use the Volvo integration instead.\n\nSteps:\n1. Remove this Volvo On Call integration.\n2. Add the Volvo integration through Settings > Devices & services > Add integration > Volvo.\n3. Follow the setup instructions to authenticate with your Volvo account." } } } diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py deleted file mode 100644 index ff32157734892..0000000000000 --- a/homeassistant/components/volvooncall/switch.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for Volvo heater.""" - -from __future__ import annotations - -from typing import Any - -from volvooncall.dashboard import Instrument - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure binary_sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call switch.""" - async_add_entities( - VolvoSwitch( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "switch" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSwitch(VolvoEntity, SwitchEntity): - """Representation of a Volvo switch.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the switch.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - @property - def is_on(self): - """Determine if switch is on.""" - return self.instrument.state - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - await self.instrument.turn_on() - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - await self.instrument.turn_off() - await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 9bc0fc1ab4104..56e3db6a2e530 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3087,9 +3087,6 @@ volkszaehler==0.4.0 # homeassistant.components.volvo volvocarsapi==0.4.2 -# homeassistant.components.volvooncall -volvooncall==0.10.3 - # homeassistant.components.verisure vsure==2.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f91022f936669..48e19389e2a91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2558,9 +2558,6 @@ voip-utils==0.3.4 # homeassistant.components.volvo volvocarsapi==0.4.2 -# homeassistant.components.volvooncall -volvooncall==0.10.3 - # homeassistant.components.verisure vsure==2.6.7 diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 5268432c17e50..206e35dd33020 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Volvo On Call config flow.""" -from unittest.mock import Mock, patch - -from aiohttp import ClientResponseError - from homeassistant import config_entries from homeassistant.components.volvooncall.const import DOMAIN from homeassistant.core import HomeAssistant @@ -13,172 +9,27 @@ async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test we get an abort with deprecation message.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_flow_aborts_with_existing_config_entry(hass: HomeAssistant) -> None: + """Test the config flow aborts even with existing config entries.""" + # Create an existing config entry + entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, ) + entry.add_to_hass(hass) - exc = ClientResponseError(Mock(), (), status=401) - - with patch( - "volvooncall.Connection.get", - side_effect=exc, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_flow_already_configured(hass: HomeAssistant) -> None: - """Test we handle a flow that has already been configured.""" - first_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-username") - first_entry.add_to_hass(hass) - + # New flow should still abort result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_form_other_exception(hass: HomeAssistant) -> None: - """Test we handle other exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "volvooncall.Connection.get", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test that we handle the reauth flow.""" - - first_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={ - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - first_entry.add_to_hass(hass) - - result = await first_entry.start_reauth_flow(hass) - - # the first form is just the confirmation prompt - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - - # the second form is the user flow where reauth happens - assert result2["type"] is FlowResultType.FORM - - with patch("volvooncall.Connection.get"): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "username": "test-username", - "password": "test-new-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" diff --git a/tests/components/volvooncall/test_init.py b/tests/components/volvooncall/test_init.py new file mode 100644 index 0000000000000..a0b65fad65943 --- /dev/null +++ b/tests/components/volvooncall/test_init.py @@ -0,0 +1,76 @@ +"""Test the Volvo On Call integration setup.""" + +from homeassistant.components.volvooncall.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_setup_entry_creates_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that setup creates a repair issue.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + assert issue.translation_key == "volvooncall_deprecated" + + +async def test_unload_entry_removes_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that unloading the last config entry removes the repair issue.""" + first_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + first_config_entry.add_to_hass(hass) + second_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call second", + data={}, + ) + second_config_entry.add_to_hass(hass) + + # Setup entry + assert await hass.config_entries.async_setup(first_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + # Check that the repair issue was created + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(first_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Check that the repair issue still exists because there's another entry + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(second_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Check that the repair issue was removed + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is None From 1a167e6aee999e98cb1ccef9d372627ff564c9ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Sep 2025 14:15:24 +0200 Subject: [PATCH 89/90] Refactor template engine: Extract context and render info (#152630) --- homeassistant/helpers/event.py | 3 +- homeassistant/helpers/template/__init__.py | 230 +++--------------- homeassistant/helpers/template/context.py | 45 ++++ .../helpers/template/extensions/math.py | 2 +- homeassistant/helpers/template/render_info.py | 155 ++++++++++++ .../helpers/trigger_template_entity.py | 4 +- tests/helpers/template/test_context.py | 91 +++++++ tests/helpers/template/test_init.py | 28 ++- tests/helpers/template/test_render_info.py | 196 +++++++++++++++ 9 files changed, 538 insertions(+), 216 deletions(-) create mode 100644 homeassistant/helpers/template/context.py create mode 100644 homeassistant/helpers/template/render_info.py create mode 100644 tests/helpers/template/test_context.py create mode 100644 tests/helpers/template/test_render_info.py diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 39cff22396a6f..8cadf4b7d4cc2 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -54,7 +54,8 @@ ) from .ratelimit import KeyedRateLimit from .sun import get_astral_event_next -from .template import RenderInfo, Template, result_as_boolean +from .template import Template, result_as_boolean +from .template.render_info import RenderInfo from .typing import TemplateVarsType _TRACK_STATE_CHANGE_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = HassKey( diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 4d9581444dda6..efe27dd015676 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -6,8 +6,6 @@ import asyncio import collections.abc from collections.abc import Callable, Generator, Iterable -from contextlib import AbstractContextManager -from contextvars import ContextVar from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps @@ -20,17 +18,8 @@ import re from struct import error as StructError, pack, unpack_from import sys -from types import CodeType, TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Concatenate, - Literal, - NoReturn, - Self, - cast, - overload, -) +from types import CodeType +from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload import weakref from awesomeversion import AwesomeVersion @@ -62,7 +51,6 @@ ServiceResponse, State, callback, - split_entity_id, valid_domain, valid_entity_id, ) @@ -88,6 +76,14 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException +from .context import ( + TemplateContextManager as TemplateContextManager, + render_with_context, + template_context_manager, + template_cv, +) +from .render_info import RenderInfo, render_info_cv + if TYPE_CHECKING: from _typeshed import OptExcInfo @@ -127,15 +123,6 @@ "name", } -ALL_STATES_RATE_LIMIT = 60 # seconds -DOMAIN_STATES_RATE_LIMIT = 1 # seconds - -_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None) - - -template_cv: ContextVar[tuple[str, str] | None] = ContextVar( - "template_cv", default=None -) # # CACHED_TEMPLATE_STATES is a rough estimate of the number of entities @@ -334,14 +321,6 @@ def __str__(self) -> str: RESULT_WRAPPERS[tuple] = TupleWrapper -def _true(arg: str) -> bool: - return True - - -def _false(arg: str) -> bool: - return False - - @lru_cache(maxsize=EVAL_CACHE_SIZE) def _cached_parse_result(render_result: str) -> Any: """Parse a result and cache the result.""" @@ -371,126 +350,6 @@ def _cached_parse_result(render_result: str) -> Any: return render_result -class RenderInfo: - """Holds information about a template render.""" - - __slots__ = ( - "_result", - "all_states", - "all_states_lifecycle", - "domains", - "domains_lifecycle", - "entities", - "exception", - "filter", - "filter_lifecycle", - "has_time", - "is_static", - "rate_limit", - "template", - ) - - def __init__(self, template: Template) -> None: - """Initialise.""" - self.template = template - # Will be set sensibly once frozen. - self.filter_lifecycle: Callable[[str], bool] = _true - self.filter: Callable[[str], bool] = _true - self._result: str | None = None - self.is_static = False - self.exception: TemplateError | None = None - self.all_states = False - self.all_states_lifecycle = False - self.domains: collections.abc.Set[str] = set() - self.domains_lifecycle: collections.abc.Set[str] = set() - self.entities: collections.abc.Set[str] = set() - self.rate_limit: float | None = None - self.has_time = False - - def __repr__(self) -> str: - """Representation of RenderInfo.""" - return ( - f"" - ) - - def _filter_domains_and_entities(self, entity_id: str) -> bool: - """Template should re-render if the entity state changes. - - Only when we match specific domains or entities. - """ - return ( - split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities - ) - - def _filter_entities(self, entity_id: str) -> bool: - """Template should re-render if the entity state changes. - - Only when we match specific entities. - """ - return entity_id in self.entities - - def _filter_lifecycle_domains(self, entity_id: str) -> bool: - """Template should re-render if the entity is added or removed. - - Only with domains watched. - """ - return split_entity_id(entity_id)[0] in self.domains_lifecycle - - def result(self) -> str: - """Results of the template computation.""" - if self.exception is not None: - raise self.exception - return cast(str, self._result) - - def _freeze_static(self) -> None: - self.is_static = True - self._freeze_sets() - self.all_states = False - - def _freeze_sets(self) -> None: - self.entities = frozenset(self.entities) - self.domains = frozenset(self.domains) - self.domains_lifecycle = frozenset(self.domains_lifecycle) - - def _freeze(self) -> None: - self._freeze_sets() - - if self.rate_limit is None: - if self.all_states or self.exception: - self.rate_limit = ALL_STATES_RATE_LIMIT - elif self.domains or self.domains_lifecycle: - self.rate_limit = DOMAIN_STATES_RATE_LIMIT - - if self.exception: - return - - if not self.all_states_lifecycle: - if self.domains_lifecycle: - self.filter_lifecycle = self._filter_lifecycle_domains - else: - self.filter_lifecycle = _false - - if self.all_states: - return - - if self.domains: - self.filter = self._filter_domains_and_entities - elif self.entities: - self.filter = self._filter_entities - else: - self.filter = _false - - class Template: """Class to hold a template and manage caching and rendering.""" @@ -572,7 +431,7 @@ def ensure_valid(self) -> None: self._compiled_code = compiled return - with _template_context_manager as cm: + with template_context_manager as cm: cm.set_template(self.template, "compiling") try: self._compiled_code = self._env.compile(self.template) @@ -631,7 +490,7 @@ def async_render( kwargs.update(variables) try: - render_result = _render_with_context(self.template, compiled, **kwargs) + render_result = render_with_context(self.template, compiled, **kwargs) except Exception as err: raise TemplateError(err) from err @@ -693,7 +552,7 @@ async def async_render_will_timeout( def _render_template() -> None: assert self.hass is not None, "hass variable not set on template" try: - _render_with_context(self.template, compiled, **kwargs) + render_with_context(self.template, compiled, **kwargs) except TimeoutError: pass except Exception: # noqa: BLE001 @@ -734,7 +593,7 @@ def async_render_to_info( if not self.hass: raise RuntimeError(f"hass not set while rendering {self}") - if _render_info.get() is not None: + if render_info_cv.get() is not None: raise RuntimeError( f"RenderInfo already set while rendering {self}, " "this usually indicates the template is being rendered " @@ -746,7 +605,7 @@ def async_render_to_info( render_info._freeze_static() # noqa: SLF001 return render_info - token = _render_info.set(render_info) + token = render_info_cv.set(render_info) try: render_info._result = self.async_render( # noqa: SLF001 variables, strict=strict, log_fn=log_fn, **kwargs @@ -754,7 +613,7 @@ def async_render_to_info( except TemplateError as ex: render_info.exception = ex finally: - _render_info.reset(token) + render_info_cv.reset(token) render_info._freeze() # noqa: SLF001 return render_info @@ -804,7 +663,7 @@ def async_render_with_possible_json_value( pass try: - render_result = _render_with_context( + render_result = render_with_context( self.template, compiled, **variables ).strip() except jinja2.TemplateError as ex: @@ -911,11 +770,11 @@ def __getattr__(self, name): __getitem__ = __getattr__ def _collect_all(self) -> None: - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.all_states = True def _collect_all_lifecycle(self) -> None: - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.all_states_lifecycle = True def __iter__(self) -> Generator[TemplateState]: @@ -1001,11 +860,11 @@ def __getattr__(self, name: str) -> TemplateState | None: __getitem__ = __getattr__ def _collect_domain(self) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.domains.add(self._domain) # type: ignore[attr-defined] def _collect_domain_lifecycle(self) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] def __iter__(self) -> Generator[TemplateState]: @@ -1043,7 +902,7 @@ def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: self._cache: dict[str, Any] = {} def _collect_state(self) -> None: - if self._collect and (render_info := _render_info.get()): + if self._collect and (render_info := render_info_cv.get()): render_info.entities.add(self._entity_id) # type: ignore[attr-defined] # Jinja will try __getitem__ first and it avoids the need @@ -1052,7 +911,7 @@ def __getitem__(self, item: str) -> Any: """Return a property as an attribute for jinja.""" if item in _COLLECTABLE_STATE_ATTRIBUTES: # _collect_state inlined here for performance - if self._collect and (render_info := _render_info.get()): + if self._collect and (render_info := render_info_cv.get()): render_info.entities.add(self._entity_id) # type: ignore[attr-defined] return getattr(self._state, item) if item == "entity_id": @@ -1194,7 +1053,7 @@ def __repr__(self) -> str: def _collect_state(hass: HomeAssistant, entity_id: str) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.entities.add(entity_id) # type: ignore[attr-defined] @@ -1945,7 +1804,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True return dt_util.now() @@ -1953,7 +1812,7 @@ def now(hass: HomeAssistant) -> datetime: def utcnow(hass: HomeAssistant) -> datetime: """Record fetching utcnow.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True return dt_util.utcnow() @@ -2356,7 +2215,7 @@ def random_every_time(context, values): def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True today = dt_util.start_of_local_day() @@ -2386,7 +2245,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: supported so as not to break old templates. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2407,7 +2266,7 @@ def time_since(hass: HomeAssistant, value: Any | datetime, precision: int = 1) - If the value not a datetime object the input will be returned unmodified. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2429,7 +2288,7 @@ def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) - If the value not a datetime object the input will be returned unmodified. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2493,35 +2352,6 @@ def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: return result -class TemplateContextManager(AbstractContextManager): - """Context manager to store template being parsed or rendered in a ContextVar.""" - - def set_template(self, template_str: str, action: str) -> None: - """Store template being parsed or rendered in a Contextvar to aid error handling.""" - template_cv.set((template_str, action)) - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """Raise any exception triggered within the runtime context.""" - template_cv.set(None) - - -_template_context_manager = TemplateContextManager() - - -def _render_with_context( - template_str: str, template: jinja2.Template, **kwargs: Any -) -> str: - """Store template being rendered in a ContextVar to aid error handling.""" - with _template_context_manager as cm: - cm.set_template(template_str, "rendering") - return template.render(**kwargs) - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: diff --git a/homeassistant/helpers/template/context.py b/homeassistant/helpers/template/context.py new file mode 100644 index 0000000000000..3f2a56fba48eb --- /dev/null +++ b/homeassistant/helpers/template/context.py @@ -0,0 +1,45 @@ +"""Template context management for Home Assistant.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from contextvars import ContextVar +from types import TracebackType +from typing import Any + +import jinja2 + +# Context variable for template string tracking +template_cv: ContextVar[tuple[str, str] | None] = ContextVar( + "template_cv", default=None +) + + +class TemplateContextManager(AbstractContextManager): + """Context manager to store template being parsed or rendered in a ContextVar.""" + + def set_template(self, template_str: str, action: str) -> None: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Raise any exception triggered within the runtime context.""" + template_cv.set(None) + + +# Global context manager instance +template_context_manager = TemplateContextManager() + + +def render_with_context( + template_str: str, template: jinja2.Template, **kwargs: Any +) -> str: + """Store template being rendered in a ContextVar to aid error handling.""" + with template_context_manager as cm: + cm.set_template(template_str, "rendering") + return template.render(**kwargs) diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py index ac64de50a476e..60b0452c3f195 100644 --- a/homeassistant/helpers/template/extensions/math.py +++ b/homeassistant/helpers/template/extensions/math.py @@ -11,7 +11,7 @@ import jinja2 from jinja2 import pass_environment -from homeassistant.helpers.template import template_cv +from homeassistant.helpers.template.context import template_cv from .base import BaseTemplateExtension, TemplateFunction diff --git a/homeassistant/helpers/template/render_info.py b/homeassistant/helpers/template/render_info.py new file mode 100644 index 0000000000000..3899ab0add154 --- /dev/null +++ b/homeassistant/helpers/template/render_info.py @@ -0,0 +1,155 @@ +"""Template render information tracking for Home Assistant.""" + +from __future__ import annotations + +import collections.abc +from collections.abc import Callable +from contextvars import ContextVar +from typing import TYPE_CHECKING, cast + +from homeassistant.core import split_entity_id + +if TYPE_CHECKING: + from homeassistant.exceptions import TemplateError + + from . import Template + +# Rate limiting constants +ALL_STATES_RATE_LIMIT = 60 # seconds +DOMAIN_STATES_RATE_LIMIT = 1 # seconds + +# Context variable for render information tracking +render_info_cv: ContextVar[RenderInfo | None] = ContextVar( + "render_info_cv", default=None +) + + +# Filter functions for efficiency +def _true(entity_id: str) -> bool: + """Return True for all entity IDs.""" + return True + + +def _false(entity_id: str) -> bool: + """Return False for all entity IDs.""" + return False + + +class RenderInfo: + """Holds information about a template render.""" + + __slots__ = ( + "_result", + "all_states", + "all_states_lifecycle", + "domains", + "domains_lifecycle", + "entities", + "exception", + "filter", + "filter_lifecycle", + "has_time", + "is_static", + "rate_limit", + "template", + ) + + def __init__(self, template: Template) -> None: + """Initialise.""" + self.template = template + # Will be set sensibly once frozen. + self.filter_lifecycle: Callable[[str], bool] = _true + self.filter: Callable[[str], bool] = _true + self._result: str | None = None + self.is_static = False + self.exception: TemplateError | None = None + self.all_states = False + self.all_states_lifecycle = False + self.domains: collections.abc.Set[str] = set() + self.domains_lifecycle: collections.abc.Set[str] = set() + self.entities: collections.abc.Set[str] = set() + self.rate_limit: float | None = None + self.has_time = False + + def __repr__(self) -> str: + """Representation of RenderInfo.""" + return ( + f"" + ) + + def _filter_domains_and_entities(self, entity_id: str) -> bool: + """Template should re-render if the entity state changes. + + Only when we match specific domains or entities. + """ + return ( + split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities + ) + + def _filter_entities(self, entity_id: str) -> bool: + """Template should re-render if the entity state changes. + + Only when we match specific entities. + """ + return entity_id in self.entities + + def _filter_lifecycle_domains(self, entity_id: str) -> bool: + """Template should re-render if the entity is added or removed. + + Only with domains watched. + """ + return split_entity_id(entity_id)[0] in self.domains_lifecycle + + def result(self) -> str: + """Results of the template computation.""" + if self.exception is not None: + raise self.exception + return cast(str, self._result) + + def _freeze_static(self) -> None: + self.is_static = True + self._freeze_sets() + self.all_states = False + + def _freeze_sets(self) -> None: + self.entities = frozenset(self.entities) + self.domains = frozenset(self.domains) + self.domains_lifecycle = frozenset(self.domains_lifecycle) + + def _freeze(self) -> None: + self._freeze_sets() + + if self.rate_limit is None: + if self.all_states or self.exception: + self.rate_limit = ALL_STATES_RATE_LIMIT + elif self.domains or self.domains_lifecycle: + self.rate_limit = DOMAIN_STATES_RATE_LIMIT + + if self.exception: + return + + if not self.all_states_lifecycle: + if self.domains_lifecycle: + self.filter_lifecycle = self._filter_lifecycle_domains + else: + self.filter_lifecycle = _false + + if self.all_states: + return + + if self.domains: + self.filter = self._filter_domains_and_entities + elif self.entities: + self.filter = self._filter_entities + else: + self.filter = _false diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index d8ebab8b83eae..46a50b184b504 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -37,10 +37,10 @@ _SENTINEL, Template, TemplateStateFromEntityId, - _render_with_context, render_complex, result_as_boolean, ) +from .template.context import render_with_context from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -131,7 +131,7 @@ def async_render_as_value_template( compiled = self._compiled or self._ensure_compiled() try: - render_result = _render_with_context( + render_result = render_with_context( self.template, compiled, **variables ).strip() except jinja2.TemplateError as ex: diff --git a/tests/helpers/template/test_context.py b/tests/helpers/template/test_context.py new file mode 100644 index 0000000000000..7773be5be2024 --- /dev/null +++ b/tests/helpers/template/test_context.py @@ -0,0 +1,91 @@ +"""Test template context management for Home Assistant.""" + +from __future__ import annotations + +import jinja2 + +from homeassistant.helpers.template.context import ( + TemplateContextManager, + render_with_context, + template_context_manager, + template_cv, +) + + +def test_template_context_manager() -> None: + """Test TemplateContextManager functionality.""" + cm = TemplateContextManager() + + # Test setting template + cm.set_template("{{ test }}", "rendering") + assert template_cv.get() == ("{{ test }}", "rendering") + + # Test context manager exit + cm.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_template_context_manager_context() -> None: + """Test TemplateContextManager as context manager.""" + cm = TemplateContextManager() + + with cm: + cm.set_template("{{ test }}", "parsing") + assert template_cv.get() == ("{{ test }}", "parsing") + + # Should be cleared after exit + assert template_cv.get() is None + + +def test_global_template_context_manager() -> None: + """Test global template context manager instance.""" + # Should be an instance of TemplateContextManager + assert isinstance(template_context_manager, TemplateContextManager) + + # Test it works like any other context manager + template_context_manager.set_template("{{ global_test }}", "testing") + assert template_cv.get() == ("{{ global_test }}", "testing") + + template_context_manager.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_render_with_context() -> None: + """Test render_with_context function.""" + # Create a simple template + env = jinja2.Environment() + template_obj = env.from_string("Hello {{ name }}!") + + # Test rendering with context tracking + result = render_with_context("Hello {{ name }}!", template_obj, name="World") + assert result == "Hello World!" + + # Context should be cleared after rendering + assert template_cv.get() is None + + +def test_render_with_context_sets_context() -> None: + """Test that render_with_context properly sets template context.""" + # Create a template that we can use to check context + jinja2.Environment() + + # We'll use a custom template class to capture context during rendering + context_during_render = [] + + class MockTemplate: + def render(self, **kwargs): + # Capture the context during rendering + context_during_render.append(template_cv.get()) + return "rendered" + + mock_template = MockTemplate() + + # Render with context + result = render_with_context("{{ test_template }}", mock_template, test=True) + + assert result == "rendered" + # Should have captured the context during rendering + assert len(context_during_render) == 1 + assert context_during_render[0] == ("{{ test_template }}", "rendering") + # Context should be cleared after rendering + assert template_cv.get() is None diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index d6df489e84215..44399869ef826 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -48,6 +48,10 @@ ) from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -127,7 +131,7 @@ async def test_template_render_missing_hass(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test", "23") template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="hass not set while rendering"): template_obj.async_render_to_info() @@ -143,7 +147,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) template_obj.hass = hass - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="RenderInfo already set while rendering"): template_obj.async_render_to_info() @@ -229,7 +233,7 @@ def test_iterating_all_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.temperature", 10) @@ -254,7 +258,7 @@ def test_iterating_all_states_unavailable(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "unknown") hass.states.async_set("sensor.temperature", 10) @@ -269,7 +273,7 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", domains=["sensor"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.back_door", "open") @@ -2663,7 +2667,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() @@ -2690,7 +2694,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT info = render_to_info( hass, @@ -3747,7 +3751,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non ) assert_result_info(info, ["sensor.a"], {"light.a", "light.b"}, {"sensor"}) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_entity_id( @@ -3771,7 +3775,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_state( @@ -3799,7 +3803,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("binary_sensor.door", "off") info = render_to_info(hass, template_complex_str) @@ -3807,7 +3811,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT template_cover_str = """ @@ -3824,7 +3828,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT def test_nested_async_render_to_info_case(hass: HomeAssistant) -> None: diff --git a/tests/helpers/template/test_render_info.py b/tests/helpers/template/test_render_info.py new file mode 100644 index 0000000000000..9b746a8461018 --- /dev/null +++ b/tests/helpers/template/test_render_info.py @@ -0,0 +1,196 @@ +"""Test template render information tracking for Home Assistant.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, + RenderInfo, + _false, + _true, + render_info_cv, +) + + +def test_render_info_initialization(hass: HomeAssistant) -> None: + """Test RenderInfo initialization.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + assert info.template is template_obj + assert info._result is None + assert info.is_static is False + assert info.exception is None + assert info.all_states is False + assert info.all_states_lifecycle is False + assert info.domains == set() + assert info.domains_lifecycle == set() + assert info.entities == set() + assert info.rate_limit is None + assert info.has_time is False + assert info.filter_lifecycle is _true + assert info.filter is _true + + +def test_render_info_repr(hass: HomeAssistant) -> None: + """Test RenderInfo representation.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + info.domains.add("sensor") + info.entities.add("sensor.test") + + repr_str = repr(info) + assert "RenderInfo" in repr_str + assert "domains={'sensor'}" in repr_str + assert "entities={'sensor.test'}" in repr_str + + +def test_render_info_result(hass: HomeAssistant) -> None: + """Test RenderInfo result property.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test with no result set - should return None cast as str + assert info.result() is None + + # Test with result set + info._result = "test_result" + assert info.result() == "test_result" + + # Test with exception + info.exception = TemplateError("Test error") + with pytest.raises(TemplateError, match="Test error"): + info.result() + + +def test_render_info_filter_domains_and_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity and domain filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Add domain and entity + info.domains.add("sensor") + info.entities.add("light.test") + + # Should match domain + assert info._filter_domains_and_entities("sensor.temperature") is True + # Should match entity + assert info._filter_domains_and_entities("light.test") is True + # Should not match + assert info._filter_domains_and_entities("switch.kitchen") is False + + +def test_render_info_filter_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity-only filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.entities.add("sensor.test") + + assert info._filter_entities("sensor.test") is True + assert info._filter_entities("sensor.other") is False + + +def test_render_info_filter_lifecycle_domains(hass: HomeAssistant) -> None: + """Test RenderInfo domain lifecycle filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains_lifecycle.add("sensor") + + assert info._filter_lifecycle_domains("sensor.test") is True + assert info._filter_lifecycle_domains("light.test") is False + + +def test_render_info_freeze_static(hass: HomeAssistant) -> None: + """Test RenderInfo static freezing.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains.add("sensor") + info.entities.add("sensor.test") + info.all_states = True + + info._freeze_static() + + assert info.is_static is True + assert info.all_states is False + assert isinstance(info.domains, frozenset) + assert isinstance(info.entities, frozenset) + + +def test_render_info_freeze(hass: HomeAssistant) -> None: + """Test RenderInfo freezing with rate limits.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test all_states rate limit + info.all_states = True + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + # Test domain rate limit + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + # Test exception rate limit + info = RenderInfo(template_obj) + info.exception = TemplateError("Test") + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + +def test_render_info_freeze_filters(hass: HomeAssistant) -> None: + """Test RenderInfo filter assignment during freeze.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + + # Test lifecycle filter assignment + info = RenderInfo(template_obj) + info.domains_lifecycle.add("sensor") + info._freeze() + assert info.filter_lifecycle == info._filter_lifecycle_domains + + # Test no lifecycle domains + info = RenderInfo(template_obj) + info._freeze() + assert info.filter_lifecycle is _false + + # Test domain and entity filter + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.filter == info._filter_domains_and_entities + + # Test entity-only filter + info = RenderInfo(template_obj) + info.entities.add("sensor.test") + info._freeze() + assert info.filter == info._filter_entities + + # Test no domains or entities + info = RenderInfo(template_obj) + info._freeze() + assert info.filter is _false + + +def test_render_info_context_var(hass: HomeAssistant) -> None: + """Test render_info_cv context variable.""" + # Should start as None + assert render_info_cv.get() is None + + # Test setting and getting + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + render_info_cv.set(info) + assert render_info_cv.get() is info + + # Reset for other tests + render_info_cv.set(None) + assert render_info_cv.get() is None From 942f7eebb1e003db67a6483cbc9e19a6d6bf719d Mon Sep 17 00:00:00 2001 From: GSzabados <35445496+GSzabados@users.noreply.github.com> Date: Sat, 20 Sep 2025 14:40:21 +0200 Subject: [PATCH 90/90] Add PM4 device class for Ecowitt (#152568) --- homeassistant/components/ecowitt/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 631910bde8652..6990bf5609986 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -253,6 +253,7 @@ ), EcoWittSensorTypes.PM4: SensorEntityDescription( key="PM4", + device_class=SensorDeviceClass.PM4, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ),