From 8f2b1f0eff6d0706160cffdb2eca9b1f814ea9e6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 29 Dec 2025 19:01:17 +0000 Subject: [PATCH 001/163] Bump version to 2026.1.0b0 --- 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 be3e4ce92fb..9e93e92679d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __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 e1ff7c440ec..b24de4c8627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0.dev0" +version = "2026.1.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 8925bfb18267dbb900e6feb5cce1fa714c1233c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Mon, 29 Dec 2025 22:12:40 +0100 Subject: [PATCH 002/163] Add translation of exceptions in met (#155765) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/met/coordinator.py | 8 ++++++-- homeassistant/components/met/strings.json | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index b2c43cb1361..0ba3b9e1626 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -116,8 +116,12 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]): """Fetch data from Met.""" try: return await self.weather.fetch_data() - except Exception as err: - raise UpdateFailed(f"Update failed: {err}") from err + except CannotConnect as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err def track_home(self) -> None: """Start tracking changes to HA home setting.""" diff --git a/homeassistant/components/met/strings.json b/homeassistant/components/met/strings.json index 6ac527775a0..7bd13b37531 100644 --- a/homeassistant/components/met/strings.json +++ b/homeassistant/components/met/strings.json @@ -19,6 +19,11 @@ } } }, + "exceptions": { + "update_failed": { + "message": "Update of data from the web site failed: {error}" + } + }, "options": { "step": { "init": { From 2346f836359d6216613b912273d204db1e48bdd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 20:14:58 +0100 Subject: [PATCH 003/163] Add integration_type device to netgear (#159816) --- homeassistant/components/netgear/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 4fecc14ba41..aa7664a77a8 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pynetgear"], "requirements": ["pynetgear==0.10.10"], From 492f2117fb6531b9dd0468fb0b0e26a3596dff0d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:12:23 +0100 Subject: [PATCH 004/163] Add integration_type service to nuheat (#159845) --- homeassistant/components/nuheat/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json index cda1e9b02dd..64cabad5bc2 100644 --- a/homeassistant/components/nuheat/manifest.json +++ b/homeassistant/components/nuheat/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/nuheat", + "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["nuheat"], "requirements": ["nuheat==1.0.1"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bcf6b005c4b..25dade80f94 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4584,7 +4584,7 @@ }, "nuheat": { "name": "NuHeat", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, From 1b9c7ae0ace44496201bdb9f002144085fa0eaf4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:12:05 +0100 Subject: [PATCH 005/163] Add integration_type hub to permobil (#159872) --- homeassistant/components/permobil/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index 2d136b28713..7bba8182c04 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@IsakNyberg"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/permobil", + "integration_type": "device", "iot_class": "cloud_polling", "requirements": ["mypermobil==0.1.8"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 25dade80f94..aba0b9485ca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5014,7 +5014,7 @@ }, "permobil": { "name": "MyPermobil", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, From a609fbc07b00c3e24709f9363f4aad60e4570b6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:11:46 +0100 Subject: [PATCH 006/163] Add integration_type hub to pooldose (#159880) --- homeassistant/components/pooldose/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 917304d5b57..0fd48657a61 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/pooldose", + "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", "requirements": ["python-pooldose==0.8.1"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index aba0b9485ca..d2d33185b36 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5152,7 +5152,7 @@ }, "pooldose": { "name": "SEKO PoolDose", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, From d2f75aec04e3de9ef6e2aa1f1773e59f4c3de127 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:11:17 +0100 Subject: [PATCH 007/163] Add integration_type hub to poolsense (#159881) --- homeassistant/components/poolsense/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/poolsense/manifest.json b/homeassistant/components/poolsense/manifest.json index 478006d0a18..f9339cfa860 100644 --- a/homeassistant/components/poolsense/manifest.json +++ b/homeassistant/components/poolsense/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@haemishkyd"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/poolsense", + "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["poolsense"], "requirements": ["poolsense==0.0.8"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d2d33185b36..e1ae6e6f2a6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5158,7 +5158,7 @@ }, "poolsense": { "name": "PoolSense", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, From cb4d62ab9af95791809a385b7dc1b554d49e2abf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:10:52 +0100 Subject: [PATCH 008/163] Add integration_type device to ps4 (#159892) --- homeassistant/components/ps4/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 1513c08ad69..ce4147924a0 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ktnrg45"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ps4", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyps4_2ndscreen"], "requirements": ["pyps4-2ndscreen==1.3.1"] From cf228ae02b2b0d9ef901e4304762a5b8b34a15fe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:18:34 +0100 Subject: [PATCH 009/163] Inject session in Switchbot cloud (#159942) --- homeassistant/components/switchbot_cloud/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index e25763bc894..4573d04e2a0 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, ENTRY_TITLE from .coordinator import SwitchBotCoordinator @@ -309,7 +310,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_API_TOKEN] secret = entry.data[CONF_API_KEY] - api = SwitchBotAPI(token=token, secret=secret) + api = SwitchBotAPI( + token=token, secret=secret, session=async_get_clientsession(hass) + ) try: devices = await api.list_devices() except SwitchBotAuthenticationError as ex: From d9fa67b16f415b91dd855f6617fa401975c65bd3 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 30 Dec 2025 00:12:45 +0100 Subject: [PATCH 010/163] bump xiaomi-ble to 1.4.1 (#159954) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 431f85810fd..513c2c72994 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["xiaomi-ble==1.2.0"] + "requirements": ["xiaomi-ble==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 96dc7aebb6f..dc5d9356e53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3213,7 +3213,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.2.0 +xiaomi-ble==1.4.1 # homeassistant.components.knx xknx==3.13.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b50afe3a51..1966ed6bfe4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2683,7 +2683,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.2.0 +xiaomi-ble==1.4.1 # homeassistant.components.knx xknx==3.13.0 From 10f6ccf6cc47be5f12512ada79a064eaa629d640 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 29 Dec 2025 20:50:53 +0100 Subject: [PATCH 011/163] Fix KNX translation references (#159959) --- homeassistant/components/knx/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index d2b69ff4f29..b6943d17e84 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -845,9 +845,9 @@ }, "mode": { "description": "Select how the entity is displayed in Home Assistant.", - "label": "[%common::config_flow::data::mode%]", + "label": "[%key:common::config_flow::data::mode%]", "options": { - "password": "[%common::config_flow::data::password%]", + "password": "[%key:common::config_flow::data::password%]", "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]" } } From e4d09bb615f98e446c317ad25186995042d2708c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Dec 2025 11:09:37 -1000 Subject: [PATCH 012/163] Bump aioesphomeapi to 43.9.1 (#159960) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9171ca5d814..34945ea643b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==43.9.0", + "aioesphomeapi==43.9.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index dc5d9356e53..14c7cd78c16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==43.9.0 +aioesphomeapi==43.9.1 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1966ed6bfe4..66c94e300c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==43.9.0 +aioesphomeapi==43.9.1 # homeassistant.components.matrix # homeassistant.components.slack From f52068600262b7c0d8068264f6f7bc1bd31565d3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 21:31:25 +0100 Subject: [PATCH 013/163] Small cleanup in Feedreader (#159962) --- homeassistant/components/feedreader/__init__.py | 8 +++----- homeassistant/components/feedreader/event.py | 3 +-- homeassistant/components/feedreader/strings.json | 6 ------ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 9acec01ee6d..11ac553513f 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -9,14 +9,12 @@ from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData -CONF_URLS = "urls" - -MY_KEY: HassKey[StoredData] = HassKey(DOMAIN) +FEEDREADER_KEY: HassKey[StoredData] = HassKey(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool: """Set up Feedreader from a config entry.""" - storage = hass.data.setdefault(MY_KEY, StoredData(hass)) + storage = hass.data.setdefault(FEEDREADER_KEY, StoredData(hass)) if not storage.is_initialized: await storage.async_setup() @@ -42,5 +40,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) ) # if this is the last entry, remove the storage if len(entries) == 1: - hass.data.pop(MY_KEY) + hass.data.pop(FEEDREADER_KEY) return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index dc7c9e880d5..4396d70e707 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -42,16 +42,15 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): _attr_event_types = [EVENT_FEEDREADER] _attr_name = None _attr_has_entity_name = True + _attr_translation_key = "latest_feed" _unrecorded_attributes = frozenset( {ATTR_CONTENT, ATTR_DESCRIPTION, ATTR_TITLE, ATTR_LINK} ) - coordinator: FeedReaderCoordinator def __init__(self, coordinator: FeedReaderCoordinator) -> None: """Initialize the feedreader event.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.config_entry.entry_id}_latest_feed" - self._attr_translation_key = "latest_feed" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name=coordinator.config_entry.title, diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index c6900b9ff81..d4e49f29a41 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -21,12 +21,6 @@ } } }, - "issues": { - "import_yaml_error_url_error": { - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually.", - "title": "The Feedreader YAML configuration import failed" - } - }, "options": { "step": { "init": { From cdd542f6e6e7d0dbe3296f21ecc15e911d565c5d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 29 Dec 2025 15:52:13 -0500 Subject: [PATCH 014/163] Bump Python-Roborock to 4.1.0 (#159963) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 51f6e65407c..659cea33eda 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==3.21.0", + "python-roborock==4.1.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 14c7cd78c16..b05949022bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.21.0 +python-roborock==4.1.0 # homeassistant.components.smarttub python-smarttub==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66c94e300c3..f6c1467f7aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.21.0 +python-roborock==4.1.0 # homeassistant.components.smarttub python-smarttub==0.0.46 From 9afb41004e075182730cc1c8e155a517745df82d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 29 Dec 2025 21:51:46 +0100 Subject: [PATCH 015/163] Portainer fix stopped container for stats (#159964) --- homeassistant/components/portainer/binary_sensor.py | 3 ++- homeassistant/components/portainer/const.py | 2 ++ homeassistant/components/portainer/coordinator.py | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index ec356923e03..13f05c89ddb 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry +from .const import CONTAINER_STATE_RUNNING from .coordinator import PortainerContainerData, PortainerCoordinator from .entity import ( PortainerContainerEntity, @@ -41,7 +42,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = PortainerContainerBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.container.state == "running", + state_fn=lambda data: data.container.state == CONTAINER_STATE_RUNNING, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index c54bffcac5a..f386f9d888b 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -4,3 +4,5 @@ DOMAIN = "portainer" DEFAULT_NAME = "Portainer" ENDPOINT_STATUS_DOWN = 2 + +CONTAINER_STATE_RUNNING = "running" diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index e87d70c1f04..9aff45f8afb 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, ENDPOINT_STATUS_DOWN +from .const import CONTAINER_STATE_RUNNING, DOMAIN, ENDPOINT_STATUS_DOWN type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -158,10 +158,11 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD ), ) for container in containers + if container.state == CONTAINER_STATE_RUNNING ] container_stats_gather = await asyncio.gather( - *[task for _, task in container_stats_task], + *[task for _, task in container_stats_task] ) for (container, _), container_stats in zip( container_stats_task, container_stats_gather, strict=False From 8d13dbdd0cece3f23040580a687c14c36fe88ce1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 Dec 2025 09:14:36 +0000 Subject: [PATCH 016/163] Bump version to 2026.1.0b1 --- 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 9e93e92679d..2e18ccb0456 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __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 b24de4c8627..9283df7b956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b0" +version = "2026.1.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 61104a997026403ce10a3b2fb26c789257cb9871 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 30 Dec 2025 18:49:02 +0100 Subject: [PATCH 017/163] Update knx-frontend to 2025.12.30.151231 (#159999) --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/strings.json | 21 +++++++++++++++++++++ homeassistant/components/knx/websocket.py | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 70417dffc93..5f5862d9219 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.13.0", "xknxproject==3.8.2", - "knx-frontend==2025.12.28.215221" + "knx-frontend==2025.12.30.151231" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index b6943d17e84..9a952044246 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -154,6 +154,27 @@ } }, "config_panel": { + "dashboard": { + "connection_flow": { + "description": "Reconfigure KNX connection or import a new KNX keyring file", + "title": "Connection settings" + }, + "options_flow": { + "description": "Configure integration settings", + "title": "Integration options" + }, + "project_upload": { + "description": "Import a KNX project file to help configure group addresses and datapoint types", + "title": "[%key:component::knx::config_panel::dialogs::project_upload::title%]" + } + }, + "dialogs": { + "project_upload": { + "description": "Details such as group address names, datapoint types, devices and group objects are extracted from your project file. The ETS project file itself and its optional password are not stored.\n\n`.knxproj` files exported by ETS 4, 5 or 6 are supported.", + "file_upload_label": "ETS project file", + "title": "Import ETS project" + } + }, "dpt": { "options": { "5": "Generic 1-byte unsigned integer", diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index c33226bf524..8629868a321 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -80,8 +80,6 @@ async def register_panel(hass: HomeAssistant) -> None: hass=hass, frontend_url_path=DOMAIN, webcomponent_name=knx_panel.webcomponent_name, - sidebar_title=DOMAIN.upper(), - sidebar_icon="mdi:bus-electric", module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, diff --git a/requirements_all.txt b/requirements_all.txt index b05949022bb..9779d26ed3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1349,7 +1349,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.28.215221 +knx-frontend==2025.12.30.151231 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6c1467f7aa..d649ff8448b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ kegtron-ble==1.0.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.28.215221 +knx-frontend==2025.12.30.151231 # homeassistant.components.konnected konnected==1.2.0 From b0a8f9575c2c5dfa654e31717baabed0b316d751 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 31 Dec 2025 10:57:58 +0100 Subject: [PATCH 018/163] Bump eternalegypt to 0.0.18 (#160006) --- .../components/netgear_lte/config_flow.py | 5 +---- .../components/netgear_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netgear_lte/test_config_flow.py | 15 --------------- 5 files changed, 4 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py index 0b8f68246ca..8eacb693089 100644 --- a/homeassistant/components/netgear_lte/config_flow.py +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER +from .const import DEFAULT_HOST, DOMAIN, MANUFACTURER class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN): @@ -72,9 +72,6 @@ class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN): info = await modem.information() except Error as ex: raise InputValidationError("cannot_connect") from ex - except Exception as ex: - LOGGER.exception("Unexpected exception") - raise InputValidationError("unknown") from ex await modem.logout() return info diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index 4ab535f8bd8..72fa7689edb 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.16"] + "requirements": ["eternalegypt==0.0.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9779d26ed3d..d7a0218797d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -929,7 +929,7 @@ esphome-dashboard-api==1.3.0 essent-dynamic-pricing==0.2.7 # homeassistant.components.netgear_lte -eternalegypt==0.0.16 +eternalegypt==0.0.18 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d649ff8448b..39eda45b462 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -820,7 +820,7 @@ esphome-dashboard-api==1.3.0 essent-dynamic-pricing==0.2.7 # homeassistant.components.netgear_lte -eternalegypt==0.0.16 +eternalegypt==0.0.18 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 diff --git a/tests/components/netgear_lte/test_config_flow.py b/tests/components/netgear_lte/test_config_flow.py index ec649f4def0..9e39ec54673 100644 --- a/tests/components/netgear_lte/test_config_flow.py +++ b/tests/components/netgear_lte/test_config_flow.py @@ -65,18 +65,3 @@ async def test_flow_user_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" - - -async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None: - """Test unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"]["base"] == "unknown" From 78bccbbbc22110883f67101305294af8fcb457b4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 31 Dec 2025 10:43:59 +0100 Subject: [PATCH 019/163] Move async_setup_services to async_setup for netgear_lte (#160007) --- homeassistant/components/netgear_lte/__init__.py | 5 +---- homeassistant/components/netgear_lte/services.py | 9 ++++++--- homeassistant/components/netgear_lte/strings.json | 5 +++++ tests/components/netgear_lte/test_services.py | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a6df67a7c83..a227fa5fe3e 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -61,6 +61,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" hass.data[DATA_HASS_CONFIG] = config + async_setup_services(hass) return True @@ -96,8 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - async_setup_services(hass) - await discovery.async_load_platform( hass, Platform.NOTIFY, @@ -118,7 +117,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not hass.config_entries.async_loaded_entries(DOMAIN): hass.data.pop(DOMAIN, None) - for service_name in hass.services.async_services()[DOMAIN]: - hass.services.async_remove(DOMAIN, service_name) return unload_ok diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py index 5cac48c2634..45b3e41189a 100644 --- a/homeassistant/components/netgear_lte/services.py +++ b/homeassistant/components/netgear_lte/services.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import ( @@ -14,7 +15,6 @@ from .const import ( AUTOCONNECT_MODES, DOMAIN, FAILOVER_MODES, - LOGGER, ) from .coordinator import NetgearLTEConfigEntry @@ -56,8 +56,11 @@ async def _service_handler(call: ServiceCall) -> None: break if not entry or not (modem := entry.runtime_data.modem).token: - LOGGER.error("%s: host %s unavailable", call.service, host) - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"service": call.service}, + ) if call.service == SERVICE_DELETE_SMS: for sms_id in call.data[ATTR_SMS_ID]: diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 7db66f6fab6..37042e268a1 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -71,6 +71,11 @@ } } }, + "exceptions": { + "config_entry_not_found": { + "message": "Failed to perform action \"{service}\". Config entry for target not found" + } + }, "services": { "connect_lte": { "description": "Asks the modem to establish the LTE connection.", diff --git a/tests/components/netgear_lte/test_services.py b/tests/components/netgear_lte/test_services.py index 58e57cf2039..a7ca18fd4d2 100644 --- a/tests/components/netgear_lte/test_services.py +++ b/tests/components/netgear_lte/test_services.py @@ -2,9 +2,12 @@ from unittest.mock import patch +import pytest + from homeassistant.components.netgear_lte.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .conftest import HOST @@ -54,3 +57,15 @@ async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None: blocking=True, ) assert len(mock_client.mock_calls) == 1 + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "delete_sms", + {CONF_HOST: "no-match", "sms_id": 1}, + blocking=True, + ) From 9b96cb66d5e0b8bfd07bf5ddf61eee85a0484692 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 31 Dec 2025 10:53:24 +0100 Subject: [PATCH 020/163] Fix netgear_lte unloading (#160008) --- homeassistant/components/netgear_lte/__init__.py | 7 ++----- homeassistant/components/netgear_lte/notify.py | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a227fa5fe3e..a2c6338f21c 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -51,7 +51,6 @@ ALL_BINARY_SENSORS = [ PLATFORMS = [ Platform.BINARY_SENSOR, - Platform.NOTIFY, Platform.SENSOR, ] @@ -101,13 +100,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - hass, Platform.NOTIFY, DOMAIN, - {CONF_NAME: entry.title, "modem": modem}, + {CONF_NAME: entry.title, "modem": modem, "entry": entry}, hass.data[DATA_HASS_CONFIG], ) - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 763581b9cad..5b10398e055 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -38,6 +38,7 @@ class NetgearNotifyService(BaseNotificationService): """Initialize the service.""" self.config = config self.modem: Modem = discovery_info["modem"] + discovery_info["entry"].async_on_unload(self.async_unregister_services) async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" From 71db8fe185dcf1a299d129a09941eb23dcef892f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 30 Dec 2025 21:13:24 +0100 Subject: [PATCH 021/163] Bump portainer 1.0.19 (#160014) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index d29ebd8149a..eb28e4521cd 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.17"] + "requirements": ["pyportainer==1.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index d7a0218797d..ee2ac8d0060 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2318,7 +2318,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.17 +pyportainer==1.0.19 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39eda45b462..4eadc2ef1b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1959,7 +1959,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.17 +pyportainer==1.0.19 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From dcdbce9b21b845e3178e4921d6cfcb347b24a749 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:10:51 +0100 Subject: [PATCH 022/163] Convert store image URLs to https in Xbox media resolver (#160015) --- homeassistant/components/xbox/media_source.py | 2 +- tests/components/xbox/test_media_source.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xbox/media_source.py b/homeassistant/components/xbox/media_source.py index 777901405ba..d7a4bde62e3 100644 --- a/homeassistant/components/xbox/media_source.py +++ b/homeassistant/components/xbox/media_source.py @@ -209,7 +209,7 @@ class XboxSource(MediaSource): if images is not None: try: return PlayMedia( - images[int(identifier.media_id)].url, + to_https(images[int(identifier.media_id)].url), MIME_TYPE_MAP[ATTR_SCREENSHOTS], ) except (ValueError, IndexError): diff --git a/tests/components/xbox/test_media_source.py b/tests/components/xbox/test_media_source.py index d0344ca61d2..aed4fb93524 100644 --- a/tests/components/xbox/test_media_source.py +++ b/tests/components/xbox/test_media_source.py @@ -275,7 +275,7 @@ async def test_browse_media_account_not_configured_exception( ), ( "/271958441785640/1297287135/game_media/0", - "http://store-images.s-microsoft.com/image/apps.35725.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.c4bf34f8-ad40-4af3-914e-a85e75a76bed", + "https://store-images.s-microsoft.com/image/apps.35725.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.c4bf34f8-ad40-4af3-914e-a85e75a76bed", "image/png", ), ], From 217eef39f34cd00cd82ac587aa8371f87cdf2c13 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 31 Dec 2025 12:31:32 +0100 Subject: [PATCH 023/163] Bump aioamazondevices to 11.0.2 (#160016) Co-authored-by: Franck Nijhof --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 7fa165ebaef..82a232fa7cf 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==10.0.0"] + "requirements": ["aioamazondevices==11.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee2ac8d0060..5ae3d70fba6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.4 # homeassistant.components.alexa_devices -aioamazondevices==10.0.0 +aioamazondevices==11.0.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4eadc2ef1b9..3ce23278a8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.4 # homeassistant.components.alexa_devices -aioamazondevices==10.0.0 +aioamazondevices==11.0.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From e503b37ddc00d21c620d0d999eebcd5403306a89 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 31 Dec 2025 04:47:48 -0500 Subject: [PATCH 024/163] Use WATER device_class for Hydrawise sensors (#160018) --- homeassistant/components/hydrawise/sensor.py | 12 +++++------ .../hydrawise/snapshots/test_sensor.ambr | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 3a04a587bb4..19fcd0295a2 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -67,21 +67,21 @@ FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( HydrawiseSensorEntityDescription( key="daily_total_water_use", translation_key="daily_total_water_use", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, suggested_display_precision=1, value_fn=lambda sensor: _get_water_use(sensor).total_use, ), HydrawiseSensorEntityDescription( key="daily_active_water_use", translation_key="daily_active_water_use", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, suggested_display_precision=1, value_fn=lambda sensor: _get_water_use(sensor).total_active_use, ), HydrawiseSensorEntityDescription( key="daily_inactive_water_use", translation_key="daily_inactive_water_use", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, suggested_display_precision=1, value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use, ), @@ -91,7 +91,7 @@ FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = ( HydrawiseSensorEntityDescription( key="daily_active_water_use", translation_key="daily_active_water_use", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, suggested_display_precision=1, value_fn=lambda sensor: float( _get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0) @@ -204,7 +204,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the sensor.""" - if self.entity_description.device_class != SensorDeviceClass.VOLUME: + if self.entity_description.device_class != SensorDeviceClass.WATER: return self.entity_description.native_unit_of_measurement return ( UnitOfVolume.GALLONS @@ -217,7 +217,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): """Icon of the entity based on the value.""" if ( self.entity_description.key in FLOW_MEASUREMENT_KEYS - and self.entity_description.device_class == SensorDeviceClass.VOLUME + and self.entity_description.device_class == SensorDeviceClass.WATER and round(self.state, 2) == 0.0 ): return "mdi:water-outline" diff --git a/tests/components/hydrawise/snapshots/test_sensor.ambr b/tests/components/hydrawise/snapshots/test_sensor.ambr index e2e97da120c..9a552db3984 100644 --- a/tests/components/hydrawise/snapshots/test_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_sensor.ambr @@ -28,7 +28,7 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Daily active water use', 'platform': 'hydrawise', @@ -44,7 +44,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by hydrawise.com', - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Home Controller Daily active water use', 'unit_of_measurement': , }), @@ -139,7 +139,7 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Daily inactive water use', 'platform': 'hydrawise', @@ -155,7 +155,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by hydrawise.com', - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Home Controller Daily inactive water use', 'unit_of_measurement': , }), @@ -196,7 +196,7 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Daily total water use', 'platform': 'hydrawise', @@ -212,7 +212,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by hydrawise.com', - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Home Controller Daily total water use', 'unit_of_measurement': , }), @@ -253,7 +253,7 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Daily active water use', 'platform': 'hydrawise', @@ -269,7 +269,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by hydrawise.com', - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Zone One Daily active water use', 'unit_of_measurement': , }), @@ -464,7 +464,7 @@ 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': 'mdi:water-outline', 'original_name': 'Daily active water use', 'platform': 'hydrawise', @@ -480,7 +480,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by hydrawise.com', - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Zone Two Daily active water use', 'icon': 'mdi:water-outline', 'unit_of_measurement': , From cbfbfbee13f943710c43474b377fa92d0ca351b8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 30 Dec 2025 16:21:54 -0500 Subject: [PATCH 025/163] Don't prefer cache for Roborock device fetching (#160022) --- homeassistant/components/roborock/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index c582d00a1af..a23866fbec4 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -79,6 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> map_scale=MAP_SCALE, ), mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass), + prefer_cache=False, ) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( From 65a259b9df85cdeb3ece5e8b0e3c55ce89941eba Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 31 Dec 2025 04:52:41 -1000 Subject: [PATCH 026/163] Fix Hikvision thread safety issue when calling async_write_ha_state (#160027) --- homeassistant/components/hikvision/binary_sensor.py | 11 +++++++---- tests/components/hikvision/test_binary_sensor.py | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index f0917c769bf..6a354458ed3 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo @@ -227,7 +227,10 @@ class HikvisionBinarySensor(BinarySensorEntity): # Register callback with pyhik self._camera.add_update_callback(self._update_callback, self._callback_id) - @callback def _update_callback(self, msg: str) -> None: - """Update the sensor's state when callback is triggered.""" - self.async_write_ha_state() + """Update the sensor's state when callback is triggered. + + This is called from pyhik's event stream thread, so we use + schedule_update_ha_state which is thread-safe. + """ + self.schedule_update_ha_state() diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 5eff8508957..7c659c1c11a 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -294,6 +294,10 @@ async def test_binary_sensor_update_callback( callback_func = add_callback_call[0][0] callback_func("motion detected") + # Wait for the event loop to process the scheduled state update + # (callback uses call_soon_threadsafe to schedule update in event loop) + await hass.async_block_till_done() + # Verify state was updated state = hass.states.get("binary_sensor.front_camera_motion") assert state is not None From d58d08c350a90496030ae742b04ec0d70004e747 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 31 Dec 2025 07:30:53 -0800 Subject: [PATCH 027/163] Filter out duplicate voices without language code in Google Cloud (#160046) --- homeassistant/components/google_cloud/helpers.py | 2 ++ tests/components/google_cloud/conftest.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f71ccea00cc..952a10482e7 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -48,6 +48,8 @@ async def async_tts_voices( list_voices_response = await client.list_voices() for voice in list_voices_response.voices: language_code = voice.language_codes[0] + if not voice.name.startswith(language_code): + continue if language_code not in voices: voices[language_code] = [] voices[language_code].append(voice.name) diff --git a/tests/components/google_cloud/conftest.py b/tests/components/google_cloud/conftest.py index 897c352b402..7e604910f6d 100644 --- a/tests/components/google_cloud/conftest.py +++ b/tests/components/google_cloud/conftest.py @@ -81,6 +81,7 @@ def mock_api_tts() -> AsyncMock: voices=[ cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"), cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"), + cloud_tts.Voice(language_codes=["en-US"], name="Standard-A"), cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"), ] ) From 42ea7ecbd6251f38b84d73cb3d35d7a778bad6f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 31 Dec 2025 15:34:05 +0000 Subject: [PATCH 028/163] Bump version to 2026.1.0b2 --- 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 2e18ccb0456..4b04799f4a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __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 9283df7b956..cf6311db077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b1" +version = "2026.1.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 53cfdef1ac1382ac5ab662dde47dd0d957fcfc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 5 Jan 2026 12:36:41 +0100 Subject: [PATCH 029/163] Move Tibber to OAuth (#156690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/tibber/__init__.py | 105 +++++- .../tibber/application_credentials.py | 15 + .../components/tibber/config_flow.py | 174 +++++++--- homeassistant/components/tibber/const.py | 29 ++ .../components/tibber/coordinator.py | 74 ++++- .../components/tibber/diagnostics.py | 31 +- homeassistant/components/tibber/manifest.json | 4 +- homeassistant/components/tibber/notify.py | 16 +- homeassistant/components/tibber/sensor.py | 145 ++++++++- homeassistant/components/tibber/services.py | 15 +- homeassistant/components/tibber/strings.json | 34 +- .../generated/application_credentials.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/conftest.py | 134 +++++++- tests/components/tibber/test_config_flow.py | 298 ++++++++++++++---- tests/components/tibber/test_diagnostics.py | 190 +++++++++-- tests/components/tibber/test_init.py | 76 ++++- tests/components/tibber/test_sensor.py | 45 +++ 19 files changed, 1178 insertions(+), 212 deletions(-) create mode 100644 homeassistant/components/tibber/application_credentials.py create mode 100644 tests/components/tibber/test_sensor.py diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 424b35b963b..6bb5c33ceb8 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -1,20 +1,36 @@ """Support for Tibber.""" +from __future__ import annotations + +from dataclasses import dataclass, field import logging import aiohttp +from aiohttp.client_exceptions import ClientError, ClientResponseError import tibber +from tibber import data_api as tibber_data_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util -from .const import DATA_HASS_CONFIG, DOMAIN +from .const import ( + AUTH_IMPLEMENTATION, + CONF_LEGACY_ACCESS_TOKEN, + DATA_HASS_CONFIG, + DOMAIN, + TibberConfigEntry, +) +from .coordinator import TibberDataAPICoordinator from .services import async_setup_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] @@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) +@dataclass +class TibberRuntimeData: + """Runtime data for Tibber API entries.""" + + tibber_connection: tibber.Tibber + session: OAuth2Session + data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) + _client: tibber_data_api.TibberDataAPI | None = None + + async def async_get_client( + self, hass: HomeAssistant + ) -> tibber_data_api.TibberDataAPI: + """Return an authenticated Tibber Data API client.""" + await self.session.async_ensure_token_valid() + token = self.session.token + access_token = token.get(CONF_ACCESS_TOKEN) + if not access_token: + raise ConfigEntryAuthFailed("Access token missing from OAuth session") + if self._client is None: + self._client = tibber_data_api.TibberDataAPI( + access_token, + websession=async_get_clientsession(hass), + ) + self._client.set_access_token(access_token) + return self._client + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tibber component.""" @@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bool: """Set up a config entry.""" + # Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API). + # Can be removed after 2026.7 + if AUTH_IMPLEMENTATION not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="data_api_reauth_required", + ) + tibber_connection = tibber.Tibber( - access_token=entry.data[CONF_ACCESS_TOKEN], + access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN], websession=async_get_clientsession(hass), time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), ) - hass.data[DOMAIN] = tibber_connection async def _close(event: Event) -> None: await tibber_connection.rt_disconnect() @@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await tibber_connection.update_info() - except ( TimeoutError, aiohttp.ClientError, @@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except tibber.FatalHttpExceptionError: return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauthentication required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + + entry.runtime_data = TibberRuntimeData( + tibber_connection=tibber_connection, + session=session, + ) + + coordinator = TibberDataAPICoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data.data_api_coordinator = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: TibberConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( + if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS - ) - if unload_ok: - tibber_connection = hass.data[DOMAIN] - await tibber_connection.rt_disconnect() + ): + await config_entry.runtime_data.tibber_connection.rt_disconnect() return unload_ok diff --git a/homeassistant/components/tibber/application_credentials.py b/homeassistant/components/tibber/application_credentials.py new file mode 100644 index 00000000000..c52beb126ab --- /dev/null +++ b/homeassistant/components/tibber/application_credentials.py @@ -0,0 +1,15 @@ +"""Application credentials platform for Tibber.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize" +TOKEN_URL = "https://thewall.tibber.com/connect/token" + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server for Tibber Data API.""" + return AuthorizationServer( + authorize_url=AUTHORIZE_URL, + token_url=TOKEN_URL, + ) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 2d4df5107a2..bc8173312c6 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -2,80 +2,164 @@ from __future__ import annotations +from collections.abc import Mapping +import logging from typing import Any import aiohttp import tibber +from tibber import data_api as tibber_data_api import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import DOMAIN +from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN -DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str}) ERR_TIMEOUT = "timeout" ERR_CLIENT = "cannot_connect" ERR_TOKEN = "invalid_access_token" TOKEN_URL = "https://developer.tibber.com/settings/access-token" +_LOGGER = logging.getLogger(__name__) -class TibberConfigFlow(ConfigFlow, domain=DOMAIN): + +class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Tibber integration.""" VERSION = 1 + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self._access_token: str | None = None + self._title = "" + + @property + def logger(self) -> logging.Logger: + """Return the logger.""" + return _LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data appended to the authorize URL.""" + return { + **super().extra_authorize_data, + "scope": " ".join(DATA_API_DEFAULT_SCOPES), + } async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - - self._async_abort_entries_match() - - if user_input is not None: - access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "") - - tibber_connection = tibber.Tibber( - access_token=access_token, - websession=async_get_clientsession(self.hass), + if user_input is None: + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""} ) - errors = {} + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=data_schema, + description_placeholders={"url": TOKEN_URL}, + errors={}, + ) - try: - await tibber_connection.update_info() - except TimeoutError: - errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT - except tibber.InvalidLoginError: - errors[CONF_ACCESS_TOKEN] = ERR_TOKEN - except ( - aiohttp.ClientError, - tibber.RetryableHttpExceptionError, - tibber.FatalHttpExceptionError, - ): - errors[CONF_ACCESS_TOKEN] = ERR_CLIENT + self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "") + tibber_connection = tibber.Tibber( + access_token=self._access_token, + websession=async_get_clientsession(self.hass), + ) + self._title = tibber_connection.name or "Tibber" - if errors: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - description_placeholders={"url": TOKEN_URL}, - errors=errors, - ) + errors: dict[str, str] = {} + try: + await tibber_connection.update_info() + except TimeoutError: + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT + except tibber.InvalidLoginError: + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN + except ( + aiohttp.ClientError, + tibber.RetryableHttpExceptionError, + tibber.FatalHttpExceptionError, + ): + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT - unique_id = tibber_connection.user_id - await self.async_set_unique_id(unique_id) + if errors: + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""} + ) + + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=data_schema, + description_placeholders={"url": TOKEN_URL}, + errors=errors, + ) + + await self.async_set_unique_id(tibber_connection.user_id) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_account", + description_placeholders={"title": reauth_entry.title}, + ) + else: self._abort_if_unique_id_configured() - return self.async_create_entry( - title=tibber_connection.name, - data={CONF_ACCESS_TOKEN: access_token}, + return await self.async_step_pick_implementation() + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a reauth flow.""" + reauth_entry = self._get_reauth_entry() + self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN) + self._title = reauth_entry.title + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication by reusing the user step.""" + reauth_entry = self._get_reauth_entry() + self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN) + self._title = reauth_entry.title + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Finalize the OAuth flow and create the config entry.""" + if self._access_token is None: + return self.async_abort(reason="missing_configuration") + + data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token + + access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + data_api_client = tibber_data_api.TibberDataAPI( + access_token, + websession=async_get_clientsession(self.hass), + ) + + try: + await data_api_client.get_userinfo() + except (aiohttp.ClientError, TimeoutError): + return self.async_abort(reason="cannot_connect") + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, + data=data, + title=self._title, ) - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - description_placeholders={"url": TOKEN_URL}, - errors={}, - ) + return self.async_create_entry(title=self._title, data=data) diff --git a/homeassistant/components/tibber/const.py b/homeassistant/components/tibber/const.py index a35fa89c40f..8a856bb95c4 100644 --- a/homeassistant/components/tibber/const.py +++ b/homeassistant/components/tibber/const.py @@ -1,5 +1,34 @@ """Constants for Tibber integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN + +if TYPE_CHECKING: + from . import TibberRuntimeData + +type TibberConfigEntry = ConfigEntry[TibberRuntimeData] + + +CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN + +AUTH_IMPLEMENTATION = "auth_implementation" DATA_HASS_CONFIG = "tibber_hass_config" DOMAIN = "tibber" MANUFACTURER = "Tibber" +DATA_API_DEFAULT_SCOPES = [ + "openid", + "profile", + "email", + "offline_access", + "data-api-user-read", + "data-api-chargers-read", + "data-api-energy-systems-read", + "data-api-homes-read", + "data-api-thermostats-read", + "data-api-vehicles-read", + "data-api-inverters-read", +] diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 2e420957c43..84fac8237c0 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -4,9 +4,11 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import cast +from typing import TYPE_CHECKING, cast +from aiohttp.client_exceptions import ClientError import tibber +from tibber.data_api import TibberDataAPI, TibberDevice from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter from .const import DOMAIN +if TYPE_CHECKING: + from .const import TibberConfigEntry + FIVE_YEARS = 5 * 365 * 24 _LOGGER = logging.getLogger(__name__) @@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__) class TibberDataCoordinator(DataUpdateCoordinator[None]): """Handle Tibber data and insert statistics.""" - config_entry: ConfigEntry + config_entry: TibberConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TibberConfigEntry, tibber_connection: tibber.Tibber, ) -> None: """Initialize the data handler.""" @@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): unit_of_measurement=unit, ) async_add_external_statistics(self.hass, metadata, statistics) + + +class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): + """Fetch and cache Tibber Data API device capabilities.""" + + config_entry: TibberConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: TibberConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} Data API", + update_interval=timedelta(minutes=1), + config_entry=entry, + ) + self._runtime_data = entry.runtime_data + self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {} + + def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None: + """Build sensor lookup dict for efficient access.""" + self.sensors_by_device = { + device_id: {sensor.id: sensor for sensor in device.sensors} + for device_id, device in devices.items() + } + + def get_sensor( + self, device_id: str, sensor_id: str + ) -> tibber.data_api.Sensor | None: + """Get a sensor by device and sensor ID.""" + if device_sensors := self.sensors_by_device.get(device_id): + return device_sensors.get(sensor_id) + return None + + async def _async_get_client(self) -> TibberDataAPI: + """Get the Tibber Data API client with error handling.""" + try: + return await self._runtime_data.async_get_client(self.hass) + except ConfigEntryAuthFailed: + raise + except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err: + raise UpdateFailed( + f"Unable to create Tibber Data API client: {err}" + ) from err + + async def _async_setup(self) -> None: + """Initial load of Tibber Data API devices.""" + client = await self._async_get_client() + devices = await client.get_all_devices() + self._build_sensor_lookup(devices) + + async def _async_update_data(self) -> dict[str, TibberDevice]: + """Fetch the latest device capabilities from the Tibber Data API.""" + client = await self._async_get_client() + devices: dict[str, TibberDevice] = await client.update_devices() + self._build_sensor_lookup(devices) + return devices diff --git a/homeassistant/components/tibber/diagnostics.py b/homeassistant/components/tibber/diagnostics.py index 2306aac23e1..9c8f9ff5ae8 100644 --- a/homeassistant/components/tibber/diagnostics.py +++ b/homeassistant/components/tibber/diagnostics.py @@ -4,21 +4,18 @@ from __future__ import annotations from typing import Any -import tibber - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import TibberConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: TibberConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - tibber_connection: tibber.Tibber = hass.data[DOMAIN] - return { + runtime = config_entry.runtime_data + result: dict[str, Any] = { "homes": [ { "last_data_timestamp": home.last_data_timestamp, @@ -27,6 +24,24 @@ async def async_get_config_entry_diagnostics( "last_cons_data_timestamp": home.last_cons_data_timestamp, "country": home.country, } - for home in tibber_connection.get_homes(only_active=False) + for home in runtime.tibber_connection.get_homes(only_active=False) ] } + + devices = ( + runtime.data_api_coordinator.data + if runtime.data_api_coordinator is not None + else {} + ) or {} + + result["devices"] = [ + { + "id": device.id, + "name": device.name, + "brand": device.brand, + "model": device.model, + } + for device in devices.values() + ] + + return result diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 0844915daa4..3e8e0246f1c 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,9 +3,9 @@ "name": "Tibber", "codeowners": ["@danielhiversen"], "config_flow": true, - "dependencies": ["recorder"], + "dependencies": ["application_credentials", "recorder"], "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.32.2"] + "requirements": ["pyTibber==0.33.1"] } diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 5a10d8e0890..b5e54a23b76 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -2,28 +2,25 @@ from __future__ import annotations -from tibber import Tibber - from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, NotifyEntity, NotifyEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, TibberConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TibberConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber notification entity.""" - async_add_entities([TibberNotificationEntity(entry.entry_id)]) + async_add_entities([TibberNotificationEntity(entry)]) class TibberNotificationEntity(NotifyEntity): @@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity): _attr_name = DOMAIN _attr_icon = "mdi:message-flash" - def __init__(self, unique_id: str) -> None: + def __init__(self, entry: TibberConfigEntry) -> None: """Initialize Tibber notify entity.""" - self._attr_unique_id = unique_id + self._attr_unique_id = entry.entry_id + self._entry = entry async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to Tibber devices.""" - tibber_connection: Tibber = self.hass.data[DOMAIN] + tibber_connection = self._entry.runtime_data.tibber_connection try: await tibber_connection.send_notification( title or ATTR_TITLE_DEFAULT, message diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index b087ef406a1..857f01c6a6a 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -10,7 +10,8 @@ from random import randrange from typing import Any import aiohttp -import tibber +from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome +from tibber.data_api import TibberDevice from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +28,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfLength, UnitOfPower, ) from homeassistant.core import Event, HomeAssistant, callback @@ -41,8 +43,8 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import Throttle, dt as dt_util -from .const import DOMAIN, MANUFACTURER -from .coordinator import TibberDataCoordinator +from .const import DOMAIN, MANUFACTURER, TibberConfigEntry +from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -260,14 +262,65 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ) +DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="storage.stateOfCharge", + translation_key="storage_state_of_charge", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="storage.targetStateOfCharge", + translation_key="storage_target_state_of_charge", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="range.remaining", + translation_key="range_remaining", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key="charging.current.max", + translation_key="charging_current_max", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="charging.current.offlineFallback", + translation_key="charging_current_offline_fallback", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TibberConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber sensor.""" - tibber_connection = hass.data[DOMAIN] + _setup_data_api_sensors(entry, async_add_entities) + await _async_setup_graphql_sensors(hass, entry, async_add_entities) + + +async def _async_setup_graphql_sensors( + hass: HomeAssistant, + entry: TibberConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tibber sensor.""" + + tibber_connection = entry.runtime_data.tibber_connection entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -280,7 +333,11 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err - except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: + except ( + RetryableHttpExceptionError, + FatalHttpExceptionError, + aiohttp.ClientError, + ) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err @@ -325,7 +382,67 @@ async def async_setup_entry( device_entry.id, new_identifiers={(DOMAIN, home.home_id)} ) - async_add_entities(entities, True) + async_add_entities(entities) + + +def _setup_data_api_sensors( + entry: TibberConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors backed by the Tibber Data API.""" + + coordinator = entry.runtime_data.data_api_coordinator + if coordinator is None: + return + + entities: list[TibberDataAPISensor] = [] + api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS} + + for device in coordinator.data.values(): + for sensor in device.sensors: + description: SensorEntityDescription | None = api_sensors.get(sensor.id) + if description is None: + _LOGGER.debug( + "Sensor %s not found in DATA_API_SENSORS, skipping", sensor + ) + continue + entities.append(TibberDataAPISensor(coordinator, device, description)) + async_add_entities(entities) + + +class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity): + """Representation of a Tibber Data API capability sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TibberDataAPICoordinator, + device: TibberDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._device_id: str = device.id + self.entity_description = entity_description + self._attr_translation_key = entity_description.translation_key + + self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.external_id)}, + name=device.name, + manufacturer=device.brand, + model=device.model, + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the device.""" + sensors = self.coordinator.sensors_by_device.get(self._device_id, {}) + sensor = sensors.get(self.entity_description.key) + return sensor.value if sensor else None class TibberSensor(SensorEntity): @@ -333,9 +450,7 @@ class TibberSensor(SensorEntity): _attr_has_entity_name = True - def __init__( - self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any - ) -> None: + def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None: """Initialize the sensor.""" super().__init__(*args, **kwargs) self._tibber_home = tibber_home @@ -366,7 +481,7 @@ class TibberSensorElPrice(TibberSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_translation_key = "electricity_price" - def __init__(self, tibber_home: tibber.TibberHome) -> None: + def __init__(self, tibber_home: TibberHome) -> None: """Initialize the sensor.""" super().__init__(tibber_home=tibber_home) self._last_updated: datetime.datetime | None = None @@ -443,7 +558,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): def __init__( self, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, coordinator: TibberDataCoordinator, entity_description: SensorEntityDescription, ) -> None: @@ -470,7 +585,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) def __init__( self, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, description: SensorEntityDescription, initial_state: float, coordinator: TibberRtDataCoordinator, @@ -532,7 +647,7 @@ class TibberRtEntityCreator: def __init__( self, async_add_entities: AddConfigEntryEntitiesCallback, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, entity_registry: er.EntityRegistry, ) -> None: """Initialize the data handler.""" @@ -618,7 +733,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en hass: HomeAssistant, config_entry: ConfigEntry, add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, ) -> None: """Initialize the data handler.""" self._add_sensor_callback = add_sensor_callback diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index d5bb3fd4854..cbe90ddda64 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt from datetime import datetime -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final import voluptuous as vol @@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN +if TYPE_CHECKING: + from .const import TibberConfigEntry + PRICE_SERVICE_NAME = "get_prices" ATTR_START: Final = "start" ATTR_END: Final = "end" @@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema( async def __get_prices(call: ServiceCall) -> ServiceResponse: - tibber_connection = call.hass.data[DOMAIN] + entries: list[TibberConfigEntry] = call.hass.config_entries.async_entries(DOMAIN) + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry", + ) + tibber_connection = entries[0].runtime_data.tibber_connection start = __get_date(call.data.get(ATTR_START), "start") end = __get_date(call.data.get(ATTR_END), "end") @@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: selected_data = [ price for price in price_data - if start <= dt.datetime.fromisoformat(price["start_time"]) < end + if start <= dt.datetime.fromisoformat(str(price["start_time"])) < end ] tibber_prices[home_nickname] = selected_data diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 8bb6cb9f08f..b0fd693891e 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +13,10 @@ "timeout": "[%key:common::config_flow::error::timeout_connect%]" }, "step": { + "reauth_confirm": { + "description": "Reconnect your Tibber account to refresh access.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]" @@ -40,6 +48,12 @@ "average_power": { "name": "Average power" }, + "charging_current_max": { + "name": "Maximum allowed charge current" + }, + "charging_current_offline_fallback": { + "name": "Fallback current if charger goes offline" + }, "current_l1": { "name": "Current L1" }, @@ -88,9 +102,18 @@ "power_production": { "name": "Power production" }, + "range_remaining": { + "name": "Estimated remaining driving range" + }, "signal_strength": { "name": "Signal strength" }, + "storage_state_of_charge": { + "name": "State of charge" + }, + "storage_target_state_of_charge": { + "name": "Target state of charge" + }, "voltage_phase1": { "name": "Voltage phase1" }, @@ -103,9 +126,18 @@ } }, "exceptions": { + "data_api_reauth_required": { + "message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features." + }, "invalid_date": { "message": "Invalid datetime provided {date}" }, + "no_config_entry": { + "message": "No Tibber integration configured" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "send_message_timeout": { "message": "Timeout sending message with Tibber" } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index f97e0e05e33..39495fbfa3a 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -39,6 +39,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "teslemetry", + "tibber", "twitch", "volvo", "watts", diff --git a/requirements_all.txt b/requirements_all.txt index 5ae3d70fba6..204edc9d9a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.32.2 +pyTibber==0.33.1 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ce23278a8d..132b37a0508 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.32.2 +pyTibber==0.33.1 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index 441a9d0b888..2f514cdeb13 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -1,24 +1,76 @@ """Test helpers for Tibber.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +import time +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import tibber +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.recorder import Recorder -from homeassistant.components.tibber.const import DOMAIN +from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +def create_tibber_device( + device_id: str = "device-id", + external_id: str = "external-id", + name: str = "Test Device", + brand: str = "Tibber", + model: str = "Gen1", + value: float | None = 72.0, + home_id: str = "home-id", +) -> tibber.data_api.TibberDevice: + """Create a fake Tibber Data API device.""" + device_data = { + "id": device_id, + "externalId": external_id, + "info": { + "name": name, + "brand": brand, + "model": model, + }, + "capabilities": [ + { + "id": "storage.stateOfCharge", + "value": value, + "description": "State of charge", + "unit": "%", + }, + { + "id": "unknown.sensor.id", + "value": None, + "description": "Unknown", + "unit": "", + }, + ], + } + return tibber.data_api.TibberDevice(device_data, home_id=home_id) + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_ACCESS_TOKEN: "token"}, + data={ + CONF_ACCESS_TOKEN: "token", + AUTH_IMPLEMENTATION: DOMAIN, + "token": { + "access_token": "test-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + }, + }, unique_id="tibber", ) config_entry.add_to_hass(hass) @@ -26,21 +78,69 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -async def mock_tibber_setup( - recorder_mock: Recorder, config_entry: MockConfigEntry, hass: HomeAssistant -) -> AsyncGenerator[MagicMock]: - """Mock tibber entry setup.""" +def _tibber_patches() -> AsyncGenerator[tuple[MagicMock, MagicMock]]: + """Patch the Tibber libraries used by the integration.""" unique_user_id = "unique_user_id" title = "title" - tibber_mock = MagicMock() - tibber_mock.update_info = AsyncMock(return_value=True) - tibber_mock.user_id = PropertyMock(return_value=unique_user_id) - tibber_mock.name = PropertyMock(return_value=title) - tibber_mock.send_notification = AsyncMock() - tibber_mock.rt_disconnect = AsyncMock() + with ( + patch( + "tibber.Tibber", + autospec=True, + ) as mock_tibber, + patch( + "tibber.data_api.TibberDataAPI", + autospec=True, + ) as mock_data_api_client, + ): + tibber_mock = mock_tibber.return_value + tibber_mock.update_info = AsyncMock(return_value=True) + tibber_mock.user_id = unique_user_id + tibber_mock.name = title + tibber_mock.send_notification = AsyncMock() + tibber_mock.rt_disconnect = AsyncMock() + tibber_mock.get_homes = MagicMock(return_value=[]) - with patch("tibber.Tibber", return_value=tibber_mock): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - yield tibber_mock + data_api_client_mock = mock_data_api_client.return_value + data_api_client_mock.get_all_devices = AsyncMock(return_value={}) + data_api_client_mock.update_devices = AsyncMock(return_value={}) + + yield tibber_mock, data_api_client_mock + + +@pytest.fixture +def tibber_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock: + """Return the patched Tibber connection mock.""" + return _tibber_patches[0] + + +@pytest.fixture +def data_api_client_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock: + """Return the patched Tibber Data API client mock.""" + return _tibber_patches[1] + + +@pytest.fixture +async def mock_tibber_setup( + recorder_mock: Recorder, + config_entry: MockConfigEntry, + hass: HomeAssistant, + tibber_mock: MagicMock, + setup_credentials: None, +) -> MagicMock: + """Mock tibber entry setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return tibber_mock + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Set up application credentials for the OAuth flow.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("test-client-id", "test-client-secret"), + DOMAIN, + ) diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 0c12c4a247b..bcd77b29eb2 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for Tibber config flow.""" -from asyncio import TimeoutError -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +import builtins +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlparse from aiohttp import ClientError import pytest @@ -13,16 +15,22 @@ from tibber import ( from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.application_credentials import TOKEN_URL from homeassistant.components.tibber.config_flow import ( + DATA_API_DEFAULT_SCOPES, ERR_CLIENT, ERR_TIMEOUT, ERR_TOKEN, ) -from homeassistant.components.tibber.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + @pytest.fixture(name="tibber_setup", autouse=True) def tibber_setup_fixture(): @@ -31,6 +39,22 @@ def tibber_setup_fixture(): yield +def _mock_tibber( + tibber_mock: MagicMock, + *, + user_id: str = "unique_user_id", + title: str = "Mock Name", + update_side_effect: Exception | None = None, +) -> MagicMock: + """Configure the patched Tibber GraphQL client.""" + tibber_mock.user_id = user_id + tibber_mock.name = title + tibber_mock.update_info = AsyncMock() + if update_side_effect is not None: + tibber_mock.update_info.side_effect = update_side_effect + return tibber_mock + + async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( @@ -41,77 +65,239 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> assert result["step_id"] == "user" -async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: - """Test create entry from user input.""" - test_data = { - CONF_ACCESS_TOKEN: "valid", - } - - unique_user_id = "unique_user_id" - title = "title" - - tibber_mock = MagicMock() - type(tibber_mock).update_info = AsyncMock(return_value=True) - type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id) - type(tibber_mock).name = PropertyMock(return_value=title) - - with patch("tibber.Tibber", return_value=tibber_mock): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == title - assert result["data"] == test_data - - @pytest.mark.parametrize( ("exception", "expected_error"), [ - (TimeoutError, ERR_TIMEOUT), - (ClientError, ERR_CLIENT), + (builtins.TimeoutError(), ERR_TIMEOUT), + (ClientError(), ERR_CLIENT), (InvalidLoginError(401), ERR_TOKEN), (RetryableHttpExceptionError(503), ERR_CLIENT), (FatalHttpExceptionError(404), ERR_CLIENT), ], ) -async def test_create_entry_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error +async def test_graphql_step_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + tibber_mock: MagicMock, + exception: Exception, + expected_error: str, ) -> None: - """Test create entry from user input.""" - test_data = { - CONF_ACCESS_TOKEN: "valid", - } + """Validate GraphQL errors are surfaced.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - unique_user_id = "unique_user_id" - title = "title" - - tibber_mock = MagicMock() - type(tibber_mock).update_info = AsyncMock(side_effect=exception) - type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id) - type(tibber_mock).name = PropertyMock(return_value=title) - - with patch("tibber.Tibber", return_value=tibber_mock): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data - ) + _mock_tibber(tibber_mock, update_side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "invalid"} + ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" assert result["errors"][CONF_ACCESS_TOKEN] == expected_error async def test_flow_entry_already_exists( - recorder_mock: Recorder, hass: HomeAssistant, config_entry + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry, + tibber_mock: MagicMock, ) -> None: """Test user input for config_entry that already exists.""" - test_data = { - CONF_ACCESS_TOKEN: "valid", - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - with patch("tibber.Tibber.update_info", return_value=None): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data - ) + _mock_tibber(tibber_mock, user_id="tibber") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "valid"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow_steps( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the reauth flow goes through reauth_confirm to user step.""" + reauth_flow = await config_entry.start_reauth_flow(hass) + + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(reauth_flow["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_oauth_create_entry_missing_configuration( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Abort OAuth finalize if GraphQL step did not run.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + handler = hass.config_entries.flow._progress[result["flow_id"]] + + flow_result = await handler.async_oauth_create_entry( + {CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}} + ) + + assert flow_result["type"] is FlowResultType.ABORT + assert flow_result["reason"] == "missing_configuration" + + +async def test_oauth_create_entry_cannot_connect_userinfo( + recorder_mock: Recorder, + hass: HomeAssistant, + data_api_client_mock: MagicMock, +) -> None: + """Abort OAuth finalize when Data API userinfo cannot be retrieved.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + handler = hass.config_entries.flow._progress[result["flow_id"]] + handler._access_token = "graphql-token" + + data_api_client_mock.get_userinfo = AsyncMock(side_effect=ClientError()) + flow_result = await handler.async_oauth_create_entry( + {CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}} + ) + + assert flow_result["type"] is FlowResultType.ABORT + assert flow_result["reason"] == "cannot_connect" + + +async def test_data_api_requires_credentials( + recorder_mock: Recorder, + hass: HomeAssistant, + tibber_mock: MagicMock, +) -> None: + """Abort when OAuth credentials are missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(tibber_mock) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "valid"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +@pytest.mark.usefixtures("setup_credentials", "current_request_with_host") +async def test_data_api_extra_authorize_scope( + hass: HomeAssistant, + tibber_mock: MagicMock, +) -> None: + """Ensure the OAuth implementation requests Tibber scopes.""" + with patch("homeassistant.components.recorder.async_setup", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(tibber_mock) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "valid"} + ) + + handler = hass.config_entries.flow._progress[result["flow_id"]] + assert handler.extra_authorize_data["scope"] == " ".join( + DATA_API_DEFAULT_SCOPES + ) + + +@pytest.mark.usefixtures("setup_credentials", "current_request_with_host") +async def test_full_flow_success( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + tibber_mock: MagicMock, + data_api_client_mock: MagicMock, +) -> None: + """Test configuring Tibber via GraphQL + OAuth.""" + with patch("homeassistant.components.recorder.async_setup", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(tibber_mock) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "graphql-token"} + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + authorize_url = result["url"] + state = parse_qs(urlparse(authorize_url).query)["state"][0] + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + + aioclient_mock.post( + TOKEN_URL, + json={ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "token_type": "bearer", + "expires_in": 3600, + }, + ) + + data_api_client_mock.get_userinfo = AsyncMock( + return_value={"name": "Mock Name"} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + data = result["data"] + assert data[CONF_TOKEN]["access_token"] == "mock-access-token" + assert data[CONF_ACCESS_TOKEN] == "graphql-token" + assert data[AUTH_IMPLEMENTATION] == DOMAIN + assert result["title"] == "Mock Name" + + +async def test_data_api_abort_when_already_configured( + recorder_mock: Recorder, + hass: HomeAssistant, + tibber_mock: MagicMock, +) -> None: + """Ensure only a single Data API entry can be configured.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={ + AUTH_IMPLEMENTATION: DOMAIN, + CONF_TOKEN: {"access_token": "existing"}, + CONF_ACCESS_TOKEN: "stored-graphql", + }, + unique_id="unique_user_id", + title="Existing", + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(tibber_mock) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "new-token"} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index 16c735596d0..021b9138c34 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -1,56 +1,178 @@ -"""Test the Netatmo diagnostics.""" +"""Test the Tibber diagnostics.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest +import tibber from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.diagnostics import ( + async_get_config_entry_diagnostics, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.exceptions import ConfigEntryAuthFailed +from .conftest import create_tibber_device from .test_common import mock_get_homes +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -async def test_entry_diagnostics( +async def test_entry_diagnostics_empty( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, + mock_tibber_setup: MagicMock, ) -> None: - """Test config entry diagnostics.""" - with patch( - "tibber.Tibber.update_info", - return_value=None, - ): - assert await async_setup_component(hass, "tibber", {}) + """Test config entry diagnostics with no homes.""" + tibber_mock = mock_tibber_setup + tibber_mock.get_homes.return_value = [] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + assert result["homes"] == [] + assert result["devices"] == [] + + +async def test_entry_diagnostics_with_homes( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_tibber_setup: MagicMock, +) -> None: + """Test config entry diagnostics with homes.""" + tibber_mock = mock_tibber_setup + tibber_mock.get_homes.side_effect = mock_get_homes + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + + homes = result["homes"] + assert isinstance(homes, list) + assert len(homes) == 1 + + home = homes[0] + assert "last_data_timestamp" in home + assert "has_active_subscription" in home + assert "has_real_time_consumption" in home + assert "last_cons_data_timestamp" in home + assert "country" in home + assert home["has_active_subscription"] is True + assert home["has_real_time_consumption"] is False + assert home["country"] == "NO" + + +async def test_data_api_diagnostics_no_data( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: MagicMock, + setup_credentials: None, +) -> None: + """Test Data API diagnostics when coordinator has no data.""" + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "tibber.Tibber.get_homes", - return_value=[], - ): - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data_api_client_mock.get_all_devices.assert_awaited_once() + data_api_client_mock.update_devices.assert_awaited_once() - assert result == { - "homes": [], + result = await async_get_config_entry_diagnostics(hass, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + assert isinstance(result["homes"], list) + assert isinstance(result["devices"], list) + assert result["devices"] == [] + + +async def test_data_api_diagnostics_with_devices( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: MagicMock, + setup_credentials: None, +) -> None: + """Test Data API diagnostics with successful device retrieval.""" + devices = { + "device-1": create_tibber_device( + device_id="device-1", + name="Device 1", + brand="Tibber", + model="Test Model", + ), + "device-2": create_tibber_device( + device_id="device-2", + name="Device 2", + brand="Tibber", + model="Test Model", + ), } - with patch( - "tibber.Tibber.get_homes", - side_effect=mock_get_homes, - ): - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data_api_client_mock.get_all_devices = AsyncMock(return_value=devices) + data_api_client_mock.update_devices = AsyncMock(return_value=devices) - assert result == { - "homes": [ - { - "last_data_timestamp": "2016-01-01T12:48:57", - "has_active_subscription": True, - "has_real_time_consumption": False, - "last_cons_data_timestamp": "2016-01-01T12:44:57", - "country": "NO", - } - ], - } + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await async_get_config_entry_diagnostics(hass, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + + devices_list = result["devices"] + assert isinstance(devices_list, list) + assert len(devices_list) == 2 + + device_1 = next((d for d in devices_list if d["id"] == "device-1"), None) + assert device_1 is not None + assert device_1["name"] == "Device 1" + assert device_1["brand"] == "Tibber" + assert device_1["model"] == "Test Model" + + device_2 = next((d for d in devices_list if d["id"] == "device-2"), None) + assert device_2 is not None + assert device_2["name"] == "Device 2" + assert device_2["brand"] == "Tibber" + assert device_2["model"] == "Test Model" + + +@pytest.mark.parametrize( + "exception", + [ + ConfigEntryAuthFailed("Auth failed"), + TimeoutError(), + aiohttp.ClientError("Connection error"), + tibber.InvalidLoginError(401), + tibber.RetryableHttpExceptionError(503), + tibber.FatalHttpExceptionError(404), + ], +) +async def test_data_api_diagnostics_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + exception: Exception, +) -> None: + """Test Data API diagnostics with various exception scenarios.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + tibber_mock.get_homes.side_effect = exception + + with pytest.raises(type(exception)): + await async_get_config_entry_diagnostics(hass, config_entry) diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py index 9e5c132c99d..3007ef34e13 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -1,11 +1,17 @@ """Test loading of the Tibber config entry.""" -from unittest.mock import MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +import pytest from homeassistant.components.recorder import Recorder -from homeassistant.components.tibber import DOMAIN +from homeassistant.components.tibber import DOMAIN, TibberRuntimeData, async_setup_entry from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from tests.common import MockConfigEntry async def test_entry_unload( @@ -19,3 +25,69 @@ async def test_entry_unload( mock_tibber_setup.rt_disconnect.assert_called_once() await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("recorder_mock") +async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: + """Ensure the data API runtime creates and caches the client.""" + session = MagicMock() + session.async_ensure_token_valid = AsyncMock() + session.token = {CONF_ACCESS_TOKEN: "access-token"} + + runtime = TibberRuntimeData( + session=session, + tibber_connection=MagicMock(), + ) + + with patch( + "homeassistant.components.tibber.tibber_data_api.TibberDataAPI" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client.set_access_token = MagicMock() + mock_client_cls.return_value = mock_client + + client = await runtime.async_get_client(hass) + + mock_client_cls.assert_called_once_with("access-token", websession=ANY) + session.async_ensure_token_valid.assert_awaited_once() + mock_client.set_access_token.assert_called_once_with("access-token") + assert client is mock_client + + mock_client.set_access_token.reset_mock() + session.async_ensure_token_valid.reset_mock() + + cached_client = await runtime.async_get_client(hass) + + mock_client_cls.assert_called_once() + session.async_ensure_token_valid.assert_awaited_once() + mock_client.set_access_token.assert_called_once_with("access-token") + assert cached_client is client + + +@pytest.mark.usefixtures("recorder_mock") +async def test_data_api_runtime_missing_token_raises(hass: HomeAssistant) -> None: + """Ensure missing tokens trigger reauthentication.""" + session = MagicMock() + session.async_ensure_token_valid = AsyncMock() + session.token = {} + + runtime = TibberRuntimeData( + session=session, + tibber_connection=MagicMock(), + ) + + with pytest.raises(ConfigEntryAuthFailed): + await runtime.async_get_client(hass) + session.async_ensure_token_valid.assert_awaited_once() + + +async def test_setup_requires_data_api_reauth(hass: HomeAssistant) -> None: + """Ensure legacy entries trigger reauth to configure Data API.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCESS_TOKEN: "legacy-token"}, + unique_id="legacy", + ) + + with pytest.raises(ConfigEntryAuthFailed): + await async_setup_entry(hass, entry) diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py new file mode 100644 index 00000000000..83b55931363 --- /dev/null +++ b/tests/components/tibber/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for the Tibber Data API sensors and coordinator.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_tibber_device + +from tests.common import MockConfigEntry + + +async def test_data_api_sensors_are_created( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: AsyncMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, +) -> None: + """Ensure Data API sensors are created and expose values from the coordinator.""" + data_api_client_mock.get_all_devices = AsyncMock( + return_value={"device-id": create_tibber_device(value=72.0)} + ) + data_api_client_mock.update_devices = AsyncMock( + return_value={"device-id": create_tibber_device(value=83.0)} + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data_api_client_mock.get_all_devices.assert_awaited_once() + data_api_client_mock.update_devices.assert_awaited_once() + + unique_id = "external-id_storage.stateOfCharge" + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 83.0 From 886348828690e211d3707c4789cf12ae2d1af247 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 3 Jan 2026 01:52:03 +1000 Subject: [PATCH 030/163] Handle export options when enrolled to VPP in Teslemetry (#157665) --- homeassistant/components/teslemetry/select.py | 25 +++- tests/components/teslemetry/test_select.py | 139 +++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index fec54b75880..9139feb9818 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -330,7 +330,9 @@ class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): self.async_write_ha_state() -class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): +class TeslemetryExportRuleSelectEntity( + TeslemetryEnergyInfoEntity, SelectEntity, RestoreEntity +): """Select entity for export rules select entities.""" _attr_options: list[str] = [ @@ -348,9 +350,28 @@ class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity) self.scoped = Scope.ENERGY_CMDS in scopes super().__init__(data, "components_customer_preferred_export_rule") + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state if it's not known + if self._attr_current_option is None: + if (state := await self.async_get_last_state()) is not None: + if state.state in self._attr_options: + self._attr_current_option = state.state + def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value) + if value := self._value: + # Customer selected export option + self._attr_current_option = value + elif self.get("components_non_export_configured") is True: + # In VPP, Export is disabled + self._attr_current_option = EnergyExportMode.NEVER + elif self._attr_current_option == EnergyExportMode.NEVER: + # In VPP, Export is enabled, but our state shows it is disabled + self._attr_current_option = None # Unknown + # In VPP Mode, Export isn't disabled, so use last known state async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index b17b52903fa..aa1acb17778 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -1,7 +1,9 @@ """Test the Teslemetry select platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode @@ -12,13 +14,16 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL from homeassistant.components.teslemetry.select import LOW from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform -from .const import COMMAND_OK, VEHICLE_DATA_ALT +from .const import COMMAND_OK, SITE_INFO, VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -164,3 +169,135 @@ async def test_select_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=entity_id) + + +async def test_export_rule_restore( + hass: HomeAssistant, + mock_site_info: AsyncMock, +) -> None: + """Test export rule entity when value is missing due to VPP enrollment.""" + # Mock energy site with missing export rule (VPP scenario) + vpp_site_info = deepcopy(SITE_INFO) + # Remove the customer_preferred_export_rule to simulate VPP enrollment + del vpp_site_info["response"]["components"]["customer_preferred_export_rule"] + mock_site_info.side_effect = lambda: vpp_site_info + + # Set up platform + entry = await setup_platform(hass, [Platform.SELECT]) + + # Entity should exist but have no current option initially + entity_id = "select.energy_site_allow_export" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Test service call works even when value is missing (VPP enrolled) + with patch( + "tesla_fleet_api.teslemetry.EnergySite.grid_import_export", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: EnergyExportMode.BATTERY_OK.value, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + call.assert_called_once() + + # Reload the platform to test state restoration + await reload_platform(hass, entry, [Platform.SELECT]) + + # The entity should restore the previous state since API value is still missing + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + + +@pytest.mark.parametrize( + ("previous_data", "new_data", "expected_state"), + [ + # Path 1: Customer selected export option (has value) + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": "pv_only", + "non_export_configured": None, + }, + EnergyExportMode.PV_ONLY.value, + ), + # Path 2: In VPP, Export is disabled (non_export_configured is True) + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": True, + }, + EnergyExportMode.NEVER.value, + ), + # Path 3: In VPP, Export enabled but state shows disabled (current_option is NEVER) + ( + { + "customer_preferred_export_rule": "never", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": None, + }, + STATE_UNKNOWN, + ), + # Path 4: In VPP Mode, Export isn't disabled, use last known state + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": None, + }, + EnergyExportMode.BATTERY_OK.value, + ), + ], +) +async def test_export_rule_update_attrs_logic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_site_info: AsyncMock, + previous_data: dict, + new_data: str | None, + expected_state: str, +) -> None: + """Test all logic paths in TeslemetryExportRuleSelectEntity._async_update_attrs.""" + # Create site info with the test data + test_site_info = deepcopy(SITE_INFO) + test_site_info["response"]["components"].update(previous_data) + mock_site_info.side_effect = lambda: test_site_info + + # Set up platform + await setup_platform(hass, [Platform.SELECT]) + + # Change the state + test_site_info = deepcopy(SITE_INFO) + test_site_info["response"]["components"].update(new_data) + mock_site_info.side_effect = lambda: test_site_info + + # Coordinator refresh + freezer.tick(ENERGY_INFO_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check the final state matches expected + state = hass.states.get("select.energy_site_allow_export") + assert state + assert state.state == expected_state From d28d55c7db1bef3c12759f106d95137748ce6ad6 Mon Sep 17 00:00:00 2001 From: Ben Wolstencroft Date: Thu, 1 Jan 2026 12:06:24 +0000 Subject: [PATCH 031/163] Add support for health_overview API endpoint to Tractive integration (#157960) Co-authored-by: Maciej Bieniek --- homeassistant/components/tractive/__init__.py | 43 +++++++++++++++++-- homeassistant/components/tractive/const.py | 1 + homeassistant/components/tractive/sensor.py | 11 ++--- tests/components/tractive/conftest.py | 32 ++++++++++++++ tests/components/tractive/test_sensor.py | 1 + 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index f00e0fec412..d60c4b657cb 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -39,6 +39,7 @@ from .const import ( SERVER_UNAVAILABLE, SWITCH_KEY_MAP, TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_HEALTH_OVERVIEW_UPDATED, TRACKER_POSITION_UPDATED, TRACKER_SWITCH_STATUS_UPDATED, TRACKER_WELLNESS_STATUS_UPDATED, @@ -64,6 +65,7 @@ class Trackables: tracker_details: dict hw_info: dict pos_report: dict + health_overview: dict @dataclass(slots=True) @@ -114,6 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Send initial health overview data to sensors after platforms are set up + for item in filtered_trackables: + if item.health_overview: + tractive.send_health_overview_update(item.health_overview) + async def cancel_listen_task(_: Event) -> None: await tractive.unsubscribe() @@ -144,9 +151,13 @@ async def _generate_trackables( return None tracker = client.tracker(trackable["device_id"]) + trackable_pet = client.trackable_object(trackable["_id"]) - tracker_details, hw_info, pos_report = await asyncio.gather( - tracker.details(), tracker.hw_info(), tracker.pos_report() + tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( + tracker.details(), + tracker.hw_info(), + tracker.pos_report(), + trackable_pet.health_overview(), ) if not tracker_details.get("_id"): @@ -154,7 +165,9 @@ async def _generate_trackables( f"Tractive API returns incomplete data for tracker {trackable['device_id']}", ) - return Trackables(tracker, trackable, tracker_details, hw_info, pos_report) + return Trackables( + tracker, trackable, tracker_details, hw_info, pos_report, health_overview + ) async def async_unload_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool: @@ -226,6 +239,9 @@ class TractiveClient: if server_was_unavailable: _LOGGER.debug("Tractive is back online") server_was_unavailable = False + if event["message"] == "health_overview": + self.send_health_overview_update(event) + continue if event["message"] == "wellness_overview": self._send_wellness_update(event) continue @@ -316,6 +332,27 @@ class TractiveClient: TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload ) + def send_health_overview_update(self, event: dict[str, Any]) -> None: + """Handle health_overview events from Tractive API.""" + # The health_overview response can be at root level or wrapped in 'content' + # Handle both structures for compatibility + data = event.get("content", event) + + activity = data.get("activity", {}) + sleep = data.get("sleep", {}) + + payload = { + ATTR_DAILY_GOAL: activity.get("minutesGoal"), + ATTR_MINUTES_ACTIVE: activity.get("minutesActive"), + ATTR_MINUTES_DAY_SLEEP: sleep.get("minutesDaySleep"), + ATTR_MINUTES_NIGHT_SLEEP: sleep.get("minutesNightSleep"), + # Calm minutes can be used as rest indicator + ATTR_MINUTES_REST: sleep.get("minutesCalm"), + } + self._dispatch_tracker_event( + TRACKER_HEALTH_OVERVIEW_UPDATED, data["petId"], payload + ) + def _send_position_update(self, event: dict[str, Any]) -> None: payload = { "latitude": event["position"]["latlong"][0], diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 9b925015772..45c357c5446 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -28,6 +28,7 @@ TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated" TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated" +TRACKER_HEALTH_OVERVIEW_UPDATED = f"{DOMAIN}_tracker_health_overview_updated" SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 18d7e4c23ab..8e79d4d48da 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -35,6 +35,7 @@ from .const import ( ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_HEALTH_OVERVIEW_UPDATED, TRACKER_WELLNESS_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -115,14 +116,14 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( key=ATTR_MINUTES_ACTIVE, translation_key="activity_time", native_unit_of_measurement=UnitOfTime.MINUTES, - signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_REST, translation_key="rest_time", native_unit_of_measurement=UnitOfTime.MINUTES, - signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -136,20 +137,20 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( key=ATTR_DAILY_GOAL, translation_key="daily_goal", native_unit_of_measurement=UnitOfTime.MINUTES, - signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_DAY_SLEEP, translation_key="minutes_day_sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_NIGHT_SLEEP, translation_key="minutes_night_sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index f32aaa84349..8354c14fb6f 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -52,6 +52,22 @@ def mock_tractive_client() -> Generator[AsyncMock]: } entry.runtime_data.client._send_wellness_update(event) + def send_health_overview_event( + entry: MockConfigEntry, event: dict[str, Any] | None = None + ): + """Send health overview event.""" + if event is None: + event = { + "petId": "pet_id_123", + "sleep": { + "minutesDaySleep": 100, + "minutesNightSleep": 300, + "minutesCalm": 122, + }, + "activity": {"minutesGoal": 200, "minutesActive": 150}, + } + entry.runtime_data.client.send_health_overview_update(event) + def send_position_event( entry: MockConfigEntry, event: dict[str, Any] | None = None ): @@ -112,8 +128,24 @@ def mock_tractive_client() -> Generator[AsyncMock]: set_led_active=AsyncMock(return_value={"pending": True}), ) + client.trackable_object.return_value = Mock( + spec=TrackableObject, + health_overview=AsyncMock( + return_value={ + "petId": "pet_id_123", + "sleep": { + "minutesDaySleep": 100, + "minutesNightSleep": 300, + "minutesCalm": 122, + }, + "activity": {"minutesGoal": 200, "minutesActive": 150}, + } + ), + ) + client.send_hardware_event = send_hardware_event client.send_wellness_event = send_wellness_event + client.send_health_overview_event = send_health_overview_event client.send_position_event = send_position_event client.send_switch_event = send_switch_event client.send_server_unavailable_event = send_server_unavailable_event diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py index 30463cd0bd9..53bd5558505 100644 --- a/tests/components/tractive/test_sensor.py +++ b/tests/components/tractive/test_sensor.py @@ -26,5 +26,6 @@ async def test_sensor( mock_tractive_client.send_hardware_event(mock_config_entry) mock_tractive_client.send_wellness_event(mock_config_entry) + mock_tractive_client.send_health_overview_event(mock_config_entry) await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From a697e63b8c754be5b75fda9a53b55081b700b3c9 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Mon, 5 Jan 2026 01:42:58 -1000 Subject: [PATCH 032/163] Fix Tesla update showing scheduled updates as installing (#158681) --- .../components/tesla_fleet/update.py | 43 ++++--- tests/components/tesla_fleet/test_update.py | 110 ++++++++++++++++-- 2 files changed, 128 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tesla_fleet/update.py b/homeassistant/components/tesla_fleet/update.py index 4e8f8a936f5..75d1a93f28e 100644 --- a/homeassistant/components/tesla_fleet/update.py +++ b/homeassistant/components/tesla_fleet/update.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from typing import Any from tesla_fleet_api.const import Scope @@ -24,6 +25,9 @@ SCHEDULED = "scheduled" PARALLEL_UPDATES = 0 +# Show scheduled update as installing if within this many seconds +SCHEDULED_THRESHOLD_SECONDS = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -69,12 +73,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity): def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - # Supported Features - if self.scoped and self._value in ( - AVAILABLE, - SCHEDULED, - ): - # Only allow install when an update has been fully downloaded + # Supported Features - only show install button if update is available + # but not already scheduled + if self.scoped and self._value == AVAILABLE: self._attr_supported_features = ( UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL ) @@ -87,13 +88,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity): # Remove build from version self._attr_installed_version = self._attr_installed_version.split(" ")[0] - # Latest Version - if self._value in ( - AVAILABLE, - SCHEDULED, - INSTALLING, - DOWNLOADING, - WIFI_WAIT, + # Latest Version - hide update if scheduled far in the future + if self._value in (AVAILABLE, INSTALLING, DOWNLOADING, WIFI_WAIT) or ( + self._value == SCHEDULED and self._is_scheduled_soon() ): self._attr_latest_version = self.coordinator.data[ "vehicle_state_software_update_version" @@ -101,14 +98,24 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity): else: self._attr_latest_version = self._attr_installed_version - # In Progress - if self._value in ( - SCHEDULED, - INSTALLING, - ): + # In Progress - only show as installing if actually installing or + # scheduled to start within 2 minutes + if self._value == INSTALLING: self._attr_in_progress = True if install_perc := self.get("vehicle_state_software_update_install_perc"): self._attr_update_percentage = install_perc + elif self._value == SCHEDULED and self._is_scheduled_soon(): + self._attr_in_progress = True + self._attr_update_percentage = None else: self._attr_in_progress = False self._attr_update_percentage = None + + def _is_scheduled_soon(self) -> bool: + """Check if a scheduled update is within the threshold to start.""" + scheduled_time_ms = self.get("vehicle_state_software_update_scheduled_time_ms") + if scheduled_time_ms is None: + return False + # Convert milliseconds to seconds and compare to current time + scheduled_time_sec = scheduled_time_ms / 1000 + return scheduled_time_sec - time.time() < SCHEDULED_THRESHOLD_SECONDS diff --git a/tests/components/tesla_fleet/test_update.py b/tests/components/tesla_fleet/test_update.py index b741470fb49..193abff14d3 100644 --- a/tests/components/tesla_fleet/test_update.py +++ b/tests/components/tesla_fleet/test_update.py @@ -1,13 +1,15 @@ """Test the Tesla Fleet update platform.""" import copy +import time +from typing import Any from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.components.tesla_fleet.coordinator import VEHICLE_INTERVAL -from homeassistant.components.tesla_fleet.update import INSTALLING +from homeassistant.components.tesla_fleet.update import INSTALLING, SCHEDULED from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -19,6 +21,11 @@ from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT from tests.common import MockConfigEntry, async_fire_time_changed +def _get_software_update(data: dict[str, Any]) -> dict[str, Any]: + """Get the software_update dict from vehicle data.""" + return data["response"]["vehicle_state"]["software_update"] + + async def test_update( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -70,14 +77,103 @@ async def test_update_services( ) call.assert_called_once() - VEHICLE_INSTALLING = copy.deepcopy(VEHICLE_DATA) - VEHICLE_INSTALLING["response"]["vehicle_state"]["software_update"]["status"] = ( # type: ignore[index] - INSTALLING - ) - mock_vehicle_data.return_value = VEHICLE_INSTALLING + vehicle_installing = copy.deepcopy(VEHICLE_DATA) + _get_software_update(vehicle_installing)["status"] = INSTALLING + mock_vehicle_data.return_value = vehicle_installing freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes["in_progress"] is True # type: ignore[union-attr] + assert state is not None + assert state.attributes["in_progress"] is True + + +async def test_update_scheduled_far_future_not_in_progress( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Tests that a scheduled update far in the future is not shown as in_progress.""" + + await setup_platform(hass, normal_config_entry, [Platform.UPDATE]) + + entity_id = "update.test_update" + + # Verify initial state (available) is not in_progress + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["in_progress"] is False + + # Simulate update being scheduled for 1 hour in the future + vehicle_scheduled = copy.deepcopy(VEHICLE_DATA) + software_update = _get_software_update(vehicle_scheduled) + software_update["status"] = SCHEDULED + # Set scheduled time to 1 hour from now (well beyond threshold) + software_update["scheduled_time_ms"] = int((time.time() + 3600) * 1000) + mock_vehicle_data.return_value = vehicle_scheduled + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Scheduled update far in future should NOT be in_progress + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["in_progress"] is False + + +async def test_update_scheduled_soon_in_progress( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Tests that a scheduled update within threshold is shown as in_progress.""" + + await setup_platform(hass, normal_config_entry, [Platform.UPDATE]) + + entity_id = "update.test_update" + + # Simulate update being scheduled within threshold (1 minute from now) + vehicle_scheduled = copy.deepcopy(VEHICLE_DATA) + software_update = _get_software_update(vehicle_scheduled) + software_update["status"] = SCHEDULED + # Set scheduled time to 1 minute from now (within 2 minute threshold) + software_update["scheduled_time_ms"] = int((time.time() + 60) * 1000) + mock_vehicle_data.return_value = vehicle_scheduled + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Scheduled update within threshold should be in_progress + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["in_progress"] is True + + +async def test_update_scheduled_no_time_not_in_progress( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Tests that a scheduled update without scheduled_time_ms is not in_progress.""" + + await setup_platform(hass, normal_config_entry, [Platform.UPDATE]) + + entity_id = "update.test_update" + + # Simulate update being scheduled but without scheduled_time_ms + vehicle_scheduled = copy.deepcopy(VEHICLE_DATA) + _get_software_update(vehicle_scheduled)["status"] = SCHEDULED + # No scheduled_time_ms field + mock_vehicle_data.return_value = vehicle_scheduled + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Scheduled update without time should NOT be in_progress + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["in_progress"] is False From 2e157f1bc6d24ef3592320dc9c421c596b1492b5 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Thu, 1 Jan 2026 16:51:39 +0100 Subject: [PATCH 033/163] Velbus Exception translations (#159627) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/velbus/__init__.py | 5 ++++- homeassistant/components/velbus/entity.py | 8 +++++++- homeassistant/components/velbus/quality_scale.yaml | 2 +- homeassistant/components/velbus/strings.json | 8 +++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index f78d3655d54..ca6bad06224 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -105,7 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo try: await controller.connect() except VelbusConnectionFailed as error: - raise ConfigEntryNotReady("Cannot connect to Velbus") from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_failed", + ) from error task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id)) entry.runtime_data = VelbusData(controller=controller, scan_task=task) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index af7ea8bbccc..aa221b3ca10 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -66,6 +66,7 @@ class VelbusEntity(Entity): self._channel.remove_on_status_update(self._on_update) async def _on_update(self) -> None: + """Handle status updates from the channel.""" self.async_write_ha_state() @@ -80,8 +81,13 @@ def api_call[_T: VelbusEntity, **_P]( try: await func(self, *args, **kwargs) except OSError as exc: + entity_name = self.name if isinstance(self.name, str) else "Unknown" raise HomeAssistantError( - f"Could not execute {func.__name__} service for {self.name}" + translation_domain=DOMAIN, + translation_key="api_call_failed", + translation_placeholders={ + "entity": entity_name, + }, ) from exc return cmd_wrapper diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 83b8bce6c0c..b0e36678029 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -56,7 +56,7 @@ rules: entity-device-class: todo entity-disabled-by-default: done entity-translations: todo - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index ff51ad066e5..d9432afa08e 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -57,8 +57,14 @@ } }, "exceptions": { + "api_call_failed": { + "message": "Action execute for {entity} failed." + }, "clear_cache_failed": { - "message": "Could not cleat the Velbus cache: {error}" + "message": "Could not clear the Velbus cache: {error}" + }, + "connection_failed": { + "message": "Could not connect to Velbus." }, "integration_not_found": { "message": "Integration \"{target}\" not found in registry." From a21062f502156fe4af9c8dbad95b67b9b254225c Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Thu, 1 Jan 2026 20:16:54 +0100 Subject: [PATCH 034/163] Add schema validation for set_hot_water_schedule service (#159990) --- homeassistant/components/bsblan/services.py | 87 ++++++++++----------- tests/components/bsblan/test_services.py | 17 ++-- 2 files changed, 47 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 7809ba54c53..bd0d876c710 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -7,11 +7,12 @@ import logging from typing import TYPE_CHECKING from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot +import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN @@ -33,28 +34,27 @@ ATTR_SUNDAY_SLOTS = "sunday_slots" SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule" -def _parse_time_value(value: time | str) -> time: - """Parse a time value from either a time object or string. +# Schema for a single time slot +_SLOT_SCHEMA = vol.Schema( + { + vol.Required("start_time"): cv.time, + vol.Required("end_time"): cv.time, + } +) - Raises ServiceValidationError if the format is invalid. - """ - if isinstance(value, time): - return value - if isinstance(value, str): - try: - parts = value.split(":") - return time(int(parts[0]), int(parts[1])) - except (ValueError, IndexError): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_time_format", - ) from None - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_time_format", - ) +SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + } +) def _convert_time_slots_to_day_schedule( @@ -62,8 +62,8 @@ def _convert_time_slots_to_day_schedule( ) -> DaySchedule | None: """Convert list of time slot dicts to a DaySchedule object. - Example: [{"start_time": "06:00", "end_time": "08:00"}, - {"start_time": "17:00", "end_time": "21:00"}] + Example: [{"start_time": time(6, 0), "end_time": time(8, 0)}, + {"start_time": time(17, 0), "end_time": time(21, 0)}] becomes: DaySchedule with two TimeSlot objects None returns None (don't modify this day). @@ -77,31 +77,27 @@ def _convert_time_slots_to_day_schedule( time_slots = [] for slot in slots: - start = slot.get("start_time") - end = slot.get("end_time") + start_time = slot["start_time"] + end_time = slot["end_time"] - if start and end: - start_time = _parse_time_value(start) - end_time = _parse_time_value(end) - - # Validate that end time is after start time - if end_time <= start_time: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_time_before_start_time", - translation_placeholders={ - "start_time": start_time.strftime("%H:%M"), - "end_time": end_time.strftime("%H:%M"), - }, - ) - - time_slots.append(TimeSlot(start=start_time, end=end_time)) - LOGGER.debug( - "Created time slot: %s-%s", - start_time.strftime("%H:%M"), - end_time.strftime("%H:%M"), + # Validate that end time is after start time + if end_time <= start_time: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="end_time_before_start_time", + translation_placeholders={ + "start_time": start_time.strftime("%H:%M"), + "end_time": end_time.strftime("%H:%M"), + }, ) + time_slots.append(TimeSlot(start=start_time, end=end_time)) + LOGGER.debug( + "Created time slot: %s-%s", + start_time.strftime("%H:%M"), + end_time.strftime("%H:%M"), + ) + LOGGER.debug("Created DaySchedule with %d slots", len(time_slots)) return DaySchedule(slots=time_slots) @@ -214,4 +210,5 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE, set_hot_water_schedule, + schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA, ) diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py index 303e49ac42f..f0955a7ac7a 100644 --- a/tests/components/bsblan/test_services.py +++ b/tests/components/bsblan/test_services.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from bsblan import BSBLANError, DaySchedule, TimeSlot import pytest +import voluptuous as vol from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.bsblan.services import ( @@ -198,9 +199,7 @@ async def test_no_config_entry_for_device( SERVICE_SET_HOT_WATER_SCHEDULE, { "device_id": device_entry.id, - "monday_slots": [ - {"start_time": time(6, 0), "end_time": time(8, 0)}, - ], + "monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}], }, blocking=True, ) @@ -274,14 +273,10 @@ async def test_api_error( [ (time(13, 0), time(11, 0), "end_time_before_start_time"), ("13:00", "11:00", "end_time_before_start_time"), - ("invalid", "08:00", "invalid_time_format"), - ("06:00", "not-a-time", "invalid_time_format"), ], ids=[ "time_objects_end_before_start", "strings_end_before_start", - "invalid_start_time_format", - "invalid_end_time_format", ], ) async def test_time_validation_errors( @@ -395,22 +390,20 @@ async def test_non_standard_time_types( device_entry: dr.DeviceEntry, ) -> None: """Test service with non-standard time types raises error.""" - # Test with integer time values (shouldn't happen but need coverage) - with pytest.raises(ServiceValidationError) as exc_info: + # Test with integer time values - schema validation will reject these + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_HOT_WATER_SCHEDULE, { "device_id": device_entry.id, "monday_slots": [ - {"start_time": 600, "end_time": 800}, # Non-standard types + {"start_time": 600, "end_time": 800}, ], }, blocking=True, ) - assert exc_info.value.translation_key == "invalid_time_format" - async def test_async_setup_services( hass: HomeAssistant, From 1034218e6efda9bff5d47a89d95e60d5b8d06540 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 31 Dec 2025 14:20:50 -0700 Subject: [PATCH 035/163] add description to string vesync (#160003) --- homeassistant/components/vesync/quality_scale.yaml | 4 +--- homeassistant/components/vesync/strings.json | 9 +++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/quality_scale.yaml b/homeassistant/components/vesync/quality_scale.yaml index e747a7e2dde..b56c9ccde23 100644 --- a/homeassistant/components/vesync/quality_scale.yaml +++ b/homeassistant/components/vesync/quality_scale.yaml @@ -5,9 +5,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: Missing data descriptions + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 938a84a73d0..23c5ab9640b 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -15,6 +15,10 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::email%]" }, + "data_description": { + "password": "[%key:component::vesync::config::step::user::data_description::password%]", + "username": "[%key:component::vesync::config::step::user::data_description::username%]" + }, "description": "The VeSync integration needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]" }, @@ -23,6 +27,11 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::email%]" }, + "data_description": { + "password": "Password associated with your VeSync account", + "username": "Email address associated with your VeSync account" + }, + "description": "Enter the account used in the vesync app. 2FA is not supported and must be disabled.", "title": "Enter username and password" } } From 026fdeb4ce22264fadc5b21110b90de7b7d9e491 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:21:25 -0500 Subject: [PATCH 036/163] Improve Sonos wait to unjoin timeout (#160011) --- homeassistant/components/sonos/speaker.py | 31 ++++++++++- tests/components/sonos/test_services.py | 65 ++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 5188e7fd414..dd1ecca4227 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -958,6 +958,23 @@ class SonosSpeaker: # as those "invisible" speakers will bypass the single speaker check return + # Clear coordinator on speakers that are no longer in this group + old_members = set(self.sonos_group[1:]) + new_members = set(sonos_group[1:]) + removed_members = old_members - new_members + for removed_speaker in removed_members: + # Only clear if this speaker was coordinated by self and in the same group + if ( + removed_speaker.coordinator == self + and removed_speaker.sonos_group is self.sonos_group + ): + _LOGGER.debug( + "Zone %s Cleared coordinator [%s] (removed from group)", + removed_speaker.zone_name, + self.zone_name, + ) + removed_speaker.clear_coordinator() + self.coordinator = None self.sonos_group = sonos_group self.sonos_group_entities = sonos_group_entities @@ -990,6 +1007,19 @@ class SonosSpeaker: return _async_handle_group_event(event) + @callback + def clear_coordinator(self) -> None: + """Clear coordinator from speaker.""" + self.coordinator = None + self.sonos_group = [self] + entity_registry = er.async_get(self.hass) + speaker_entity_id = cast( + str, + entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.uid), + ) + self.sonos_group_entities = [speaker_entity_id] + self.async_write_entity_states() + @soco_error() def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]: """Form a group with other players.""" @@ -1038,7 +1068,6 @@ class SonosSpeaker: if self.sonos_group == [self]: return self.soco.unjoin() - self.coordinator = None @staticmethod async def unjoin_multi( diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 3bc85d3b166..7e30e1e2125 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MockSoCo, group_speakers, ungroup_speakers +from .conftest import MockSoCo, create_zgs_sonos_event, group_speakers, ungroup_speakers async def test_media_player_join( @@ -134,6 +134,7 @@ async def test_media_player_join_timeout( "Timeout while waiting for Sonos player to join the " "group Living Room: Living Room, Bedroom" ) + with ( patch( "homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout @@ -247,3 +248,65 @@ async def test_media_player_unjoin_already_unjoined( # Should not have called unjoin, since the speakers are already unjoined. assert soco_bedroom.unjoin.call_count == 0 assert soco_living_room.unjoin.call_count == 0 + + +async def test_unjoin_completes_when_coordinator_receives_event_first( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that unjoin completes even when only coordinator receives ZGS event.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + # First, group the speakers together + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify initial grouped state + expected_group = ["media_player.living_room", "media_player.bedroom"] + assert ( + hass.states.get("media_player.living_room").attributes["group_members"] + == expected_group + ) + assert ( + hass.states.get("media_player.bedroom").attributes["group_members"] + == expected_group + ) + + unjoin_complete_event = asyncio.Event() + + def mock_unjoin(*args, **kwargs) -> None: + hass.loop.call_soon_threadsafe(unjoin_complete_event.set) + + soco_bedroom.unjoin = Mock(side_effect=mock_unjoin) + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: "media_player.bedroom"}, + blocking=False, + ) + await unjoin_complete_event.wait() + + # Fire ZGS event only to coordinator to test clearing of bedroom speaker + ungroup_event = create_zgs_sonos_event( + "zgs_two_single.xml", + soco_living_room, + soco_bedroom, + create_uui_ds_in_group=False, + ) + soco_living_room.zoneGroupTopology.subscribe.return_value._callback( + ungroup_event + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Should complete without warnings or timeout errors + assert len(caplog.records) == 0 + assert soco_bedroom.unjoin.call_count == 1 + state = hass.states.get("media_player.living_room") + assert state.attributes["group_members"] == ["media_player.living_room"] + state = hass.states.get("media_player.bedroom") + assert state.attributes["group_members"] == ["media_player.bedroom"] From 6c006c68c13b479413c4c1d6c3a63efa483379d1 Mon Sep 17 00:00:00 2001 From: Miguel Camba Date: Thu, 1 Jan 2026 20:07:06 +0100 Subject: [PATCH 037/163] Update voluptuous and voluptuous-openapi (#160073) --- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 4 ++-- requirements.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7f2d846a6d3..af90121950f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,9 +70,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.2.0 +voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 -voluptuous==0.15.2 +voluptuous==0.16.0 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 diff --git a/pyproject.toml b/pyproject.toml index cf6311db077..a00cbf9f04d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,9 @@ dependencies = [ "ulid-transform==1.5.2", "urllib3>=2.0", "uv==0.9.17", - "voluptuous==0.15.2", + "voluptuous==0.16.0", "voluptuous-serialize==2.7.0", - "voluptuous-openapi==0.2.0", + "voluptuous-openapi==0.3.0", "yarl==1.22.0", "webrtc-models==0.3.0", "zeroconf==0.148.0", diff --git a/requirements.txt b/requirements.txt index be409ed1b60..c4a1a0afa2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,9 +54,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.2.0 +voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 -voluptuous==0.15.2 +voluptuous==0.16.0 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 From 77367e415f564e703ea5c77895bfcb71141fc6eb Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Thu, 1 Jan 2026 11:02:56 -0800 Subject: [PATCH 038/163] Bump total_connect_client to 2025.12.2 (#160075) --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/test_alarm_control_panel.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index cd349cd3414..db9a53ac154 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.5"] + "requirements": ["total-connect-client==2025.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 204edc9d9a0..1847d350699 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3039,7 +3039,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.5 +total-connect-client==2025.12.2 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 132b37a0508..77043ab6fc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2533,7 +2533,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.5 +total-connect-client==2025.12.2 # homeassistant.components.tplink_omada tplink-omada-client==1.5.3 diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 040cdf5d9ed..8be8eadbb26 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -346,9 +346,9 @@ async def test_instant_arming_exceptions( (ArmingState.ARMED_STAY_PROA7, AlarmControlPanelState.ARMED_HOME), (ArmingState.ARMED_STAY_BYPASS, AlarmControlPanelState.ARMED_HOME), (ArmingState.ARMED_STAY_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME), - (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_NIGHT), (ArmingState.ARMED_STAY_INSTANT_PROA7, AlarmControlPanelState.ARMED_HOME), - (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_NIGHT), ( ArmingState.ARMED_STAY_INSTANT_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME, From d6082ab6c32bc3967980e412c18bd63f3aacc7a0 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Thu, 1 Jan 2026 16:50:28 +0100 Subject: [PATCH 039/163] Bump velbusaio to 2026.1.0 (#160087) --- homeassistant/components/velbus/climate.py | 2 +- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index e31d9a97416..4eb9db94ec7 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -65,7 +65,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): ) @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._channel.get_state() diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ee5a89160a5..0b1141a7da0 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.12.0"], + "requirements": ["velbus-aio==2026.1.0"], "usb": [ { "pid": "0B1B", diff --git a/requirements_all.txt b/requirements_all.txt index 1847d350699..549dc8ae149 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3122,7 +3122,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.12.0 +velbus-aio==2026.1.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77043ab6fc6..69abf626bbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2607,7 +2607,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.12.0 +velbus-aio==2026.1.0 # homeassistant.components.venstar venstarcolortouch==0.21 From c4012fae4e727fe99ef09ccb4182668b97df990b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 1 Jan 2026 12:58:37 -0600 Subject: [PATCH 040/163] Bump intents to 2026.1.1 (#160099) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c6f4e4e3eb1..2053bf90255 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af90121950f..74272364832 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251229.0 -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index c4a1a0afa2f..bc7d83e3f22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index 549dc8ae149..2e0a36fd1f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1216,7 +1216,7 @@ holidays==0.84 home-assistant-frontend==20251229.0 # homeassistant.components.conversation -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69abf626bbb..90c8b31a304 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ holidays==0.84 home-assistant-frontend==20251229.0 # homeassistant.components.conversation -home-assistant-intents==2025.12.2 +home-assistant-intents==2026.1.1 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 From abf7078842a33aa8b8513e2c87cef2337b3bd583 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 1 Jan 2026 21:56:35 +0100 Subject: [PATCH 041/163] Fix reolink brightness scaling (#160106) --- homeassistant/components/reolink/light.py | 9 +++++---- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_light.py | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index a5826e9bb8c..56e31c771e4 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .entity import ( ReolinkChannelCoordinatorEntity, @@ -157,16 +158,16 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): @property def brightness(self) -> int | None: - """Return the brightness of this light between 0.255.""" + """Return the brightness of this light between 1.255.""" assert self.entity_description.get_brightness_fn is not None bright_pct = self.entity_description.get_brightness_fn( self._host.api, self._channel ) - if bright_pct is None: + if not bright_pct: return None - return round(255 * bright_pct / 100.0) + return color_util.value_to_brightness((1, 100), bright_pct) @property def color_temp_kelvin(self) -> int | None: @@ -189,7 +190,7 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): if ( brightness := kwargs.get(ATTR_BRIGHTNESS) ) is not None and self.entity_description.set_brightness_fn is not None: - brightness_pct = int(brightness / 255.0 * 100) + brightness_pct = round(color_util.brightness_to_value((1, 100), brightness)) await self.entity_description.set_brightness_fn( self._host.api, self._channel, brightness_pct ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a766846175a..ea9975ad683 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -185,6 +185,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" + host_mock.whiteled_brightness.return_value = None def ai_detect_type(channel: int, object_type: str) -> str | None: if object_type == "people": diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index a9c2d8cc1bf..0896c1df87d 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -74,6 +74,7 @@ async def test_light_turn_off( ) -> None: """Test light turn off service.""" reolink_host.whiteled_color_temperature.return_value = 3000 + reolink_host.whiteled_brightness.return_value = 75 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -81,6 +82,8 @@ async def test_light_turn_off( assert config_entry.state is ConfigEntryState.LOADED entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" + state = hass.states.get(entity_id) + assert state and state.attributes.get(ATTR_BRIGHTNESS) == 191 await hass.services.async_call( LIGHT_DOMAIN, @@ -107,6 +110,7 @@ async def test_light_turn_on( ) -> None: """Test light turn on service.""" reolink_host.whiteled_color_temperature.return_value = 3000 + reolink_host.whiteled_brightness.return_value = None with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) From 0861b7541db0431672a31f415fa7f7f44d1b0e25 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 2 Jan 2026 09:16:27 +0100 Subject: [PATCH 042/163] Bump velbusaio to 2026.1.1 (#160116) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 0b1141a7da0..4270a40a6d7 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2026.1.0"], + "requirements": ["velbus-aio==2026.1.1"], "usb": [ { "pid": "0B1B", diff --git a/requirements_all.txt b/requirements_all.txt index 2e0a36fd1f7..e3ee0ee9790 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3122,7 +3122,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.1.0 +velbus-aio==2026.1.1 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90c8b31a304..a94232966b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2607,7 +2607,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.1.0 +velbus-aio==2026.1.1 # homeassistant.components.venstar venstarcolortouch==0.21 From 5d08481137953e70e77c933c288d505c7f91ea49 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:29:29 +0200 Subject: [PATCH 043/163] Bump pyairobotrest to 0.2.0 (#160125) --- homeassistant/components/airobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json index 861f220b169..14d7e311acc 100644 --- a/homeassistant/components/airobot/manifest.json +++ b/homeassistant/components/airobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["pyairobotrest"], "quality_scale": "silver", - "requirements": ["pyairobotrest==0.1.0"] + "requirements": ["pyairobotrest==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e3ee0ee9790..b8ecb9660e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1891,7 +1891,7 @@ pyaftership==21.11.0 pyairnow==1.3.1 # homeassistant.components.airobot -pyairobotrest==0.1.0 +pyairobotrest==0.2.0 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a94232966b7..d003a03302c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1613,7 +1613,7 @@ pyaftership==21.11.0 pyairnow==1.3.1 # homeassistant.components.airobot -pyairobotrest==0.1.0 +pyairobotrest==0.2.0 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro From 37d82ab7953abfaf6262cedca7cdfccbd10dbd47 Mon Sep 17 00:00:00 2001 From: wollew Date: Fri, 2 Jan 2026 16:49:09 +0100 Subject: [PATCH 044/163] bump pyvlx version to 0.2.27 (#160139) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index e6ab6089b71..4790eadaa85 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.26"] + "requirements": ["pyvlx==0.2.27"] } diff --git a/requirements_all.txt b/requirements_all.txt index b8ecb9660e8..b62f0bbf802 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2657,7 +2657,7 @@ pyvesync==3.3.3 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.26 +pyvlx==0.2.27 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d003a03302c..e8681076106 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2229,7 +2229,7 @@ pyvesync==3.3.3 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.26 +pyvlx==0.2.27 # homeassistant.components.volumio pyvolumio==0.1.5 From d5ebd02afef4c90f006cb1589048838a6475cf60 Mon Sep 17 00:00:00 2001 From: Vincent Courcelle Date: Mon, 5 Jan 2026 03:26:50 +0400 Subject: [PATCH 045/163] Bump python-roborock to 4.2.0 (#160184) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 659cea33eda..b025cb1e6cb 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==4.1.0", + "python-roborock==4.2.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index b62f0bbf802..cf3eccab3f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==4.1.0 +python-roborock==4.2.0 # homeassistant.components.smarttub python-smarttub==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8681076106..50492002af1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==4.1.0 +python-roborock==4.2.0 # homeassistant.components.smarttub python-smarttub==0.0.46 From ec19529c9914a4426104734edb103508cdc4bb3c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 3 Jan 2026 17:12:53 +0100 Subject: [PATCH 046/163] Remove referral link from fish_audio (#160193) --- homeassistant/components/fish_audio/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fish_audio/const.py b/homeassistant/components/fish_audio/const.py index 3861cd99fdb..bbff953e3bf 100644 --- a/homeassistant/components/fish_audio/const.py +++ b/homeassistant/components/fish_audio/const.py @@ -35,6 +35,6 @@ BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"] SORT_BY_OPTIONS = ["task_count", "score", "created_at"] LATENCY_OPTIONS = ["normal", "balanced"] -SIGNUP_URL = "https://fish.audio/?fpr=homeassistant" # codespell:ignore fpr +SIGNUP_URL = "https://fish.audio/" BILLING_URL = "https://fish.audio/app/billing/" API_KEYS_URL = "https://fish.audio/app/api-keys/" From 85c2351af21b6aa98ee7d12432d637e5303dcf67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jan 2026 08:08:42 -1000 Subject: [PATCH 047/163] Ensure Brotli >= 1.2.0 (#160229) --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 74272364832..8b35de56a2c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -129,6 +129,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 +# Brotli 1.2.0 fixes CVE and is required for aiohttp 3.13.3 compatibility +Brotli>=1.2.0 + # ensure pydantic version does not float since it might have breaking changes pydantic==2.12.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 29c0e835b26..0f542aeb1bb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -120,6 +120,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 +# Brotli 1.2.0 fixes CVE and is required for aiohttp 3.13.3 compatibility +Brotli>=1.2.0 + # ensure pydantic version does not float since it might have breaking changes pydantic==2.12.2 From b3f123c71519beb68b753ac8aaf0080dcd4ed392 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jan 2026 13:41:41 +0100 Subject: [PATCH 048/163] Await writes in shopping_list action handlers (#157420) --- .../components/shopping_list/__init__.py | 14 +- tests/components/shopping_list/conftest.py | 18 +- .../shopping_list/snapshots/test_init.ambr | 717 ++++++++++++++++++ tests/components/shopping_list/test_init.py | 192 ++++- 4 files changed, 901 insertions(+), 40 deletions(-) create mode 100644 tests/components/shopping_list/snapshots/test_init.ambr diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 97c6ed135c3..e60acf4b377 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -325,8 +325,7 @@ class ShoppingData: ) return self.items - @callback - def async_reorder( + async def async_reorder( self, item_ids: list[str], context: Context | None = None ) -> None: """Reorder items.""" @@ -351,7 +350,7 @@ class ShoppingData: ) new_items.append(value) self.items = new_items - self.hass.async_add_executor_job(self.save) + await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, @@ -388,7 +387,7 @@ class ShoppingData: ) -> None: """Sort items by name.""" self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] - self.hass.async_add_executor_job(self.save) + await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, @@ -591,7 +590,8 @@ async def websocket_handle_clear( vol.Required("item_ids"): [str], } ) -def websocket_handle_reorder( +@websocket_api.async_response +async def websocket_handle_reorder( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -599,7 +599,9 @@ def websocket_handle_reorder( """Handle reordering shopping_list items.""" msg_id = msg.pop("id") try: - hass.data[DOMAIN].async_reorder(msg.pop("item_ids"), connection.context(msg)) + await hass.data[DOMAIN].async_reorder( + msg.pop("item_ids"), connection.context(msg) + ) except NoMatchingShoppingListItem: connection.send_error( msg_id, diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index dd1b690e1e3..69d214efe18 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -1,6 +1,8 @@ """Shopping list test helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from contextlib import suppress +import os import pytest @@ -11,13 +13,13 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_shopping_list_io(): - """Stub out the persistence.""" - with ( - patch("homeassistant.components.shopping_list.ShoppingData.save"), - patch("homeassistant.components.shopping_list.ShoppingData.async_load"), - ): +def wipe_shopping_list_store(hass: HomeAssistant) -> Generator[None]: + """Wipe shopping list store after test.""" + try: yield + finally: + with suppress(FileNotFoundError): + os.remove(hass.config.path(".shopping_list.json")) @pytest.fixture @@ -27,7 +29,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry): +async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Set up the shopping list.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/shopping_list/snapshots/test_init.ambr b/tests/components/shopping_list/snapshots/test_init.ambr new file mode 100644 index 00000000000..0e30e9bf059 --- /dev/null +++ b/tests/components/shopping_list/snapshots/test_init.ambr @@ -0,0 +1,717 @@ +# serializer version: 1 +# name: test_add_item + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_add_item_service + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_api_update_fails + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_api_update_fails.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_api_update_fails.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_clear_completed_items + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_clear_completed_items.1 + list([ + dict({ + 'complete': True, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': True, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_clear_completed_items.2 + list([ + ]) +# --- +# name: test_clear_completed_items_service + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_clear_completed_items_service.1 + list([ + dict({ + 'complete': True, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_clear_completed_items_service.2 + list([ + ]) +# --- +# name: test_deprecated_api_clear_completed + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_clear_completed.1 + list([ + dict({ + 'complete': True, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_clear_completed.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_create + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_deprecated_api_create_fail + '' +# --- +# name: test_deprecated_api_get_all + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_get_all.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_update + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_update.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_deprecated_api_update.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + dict({ + 'complete': True, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_recent_items_intent + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_recent_items_intent.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_remove_item + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_remove_item.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_remove_item.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_remove_item_service + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_remove_item_service.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_sort_list_service + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'zzz', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'ddd', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'aaa', + }), + ]) +# --- +# name: test_sort_list_service.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'aaa', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'ddd', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'zzz', + }), + ]) +# --- +# name: test_sort_list_service.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'zzz', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'ddd', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'aaa', + }), + ]) +# --- +# name: test_update_list + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_update_list.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'dupe', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'dupe', + }), + ]) +# --- +# name: test_ws_add_item + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_ws_add_item_fail + '' +# --- +# name: test_ws_clear_items + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_clear_items.1 + list([ + dict({ + 'complete': True, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_clear_items.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_get_items + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_get_items.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_remove_item + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_ws_remove_item.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'cheese', + }), + ]) +# --- +# name: test_ws_remove_item_fail + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_ws_remove_item_fail.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + ]) +# --- +# name: test_ws_reorder_items + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + ]) +# --- +# name: test_ws_reorder_items.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_ws_reorder_items.2 + list([ + dict({ + 'complete': True, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_ws_reorder_items.3 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': True, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_reorder_items_failure + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + ]) +# --- +# name: test_ws_reorder_items_failure.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + ]) +# --- +# name: test_ws_reorder_items_failure.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'apple', + }), + ]) +# --- +# name: test_ws_update_item + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_update_item.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + dict({ + 'complete': False, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_update_item.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'soda', + }), + dict({ + 'complete': True, + 'id': '', + 'name': 'wine', + }), + ]) +# --- +# name: test_ws_update_item_fail + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_ws_update_item_fail.1 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- +# name: test_ws_update_item_fail.2 + list([ + dict({ + 'complete': False, + 'id': '', + 'name': 'beer', + }), + ]) +# --- diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 276602f794e..644f28c64d2 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,8 +1,11 @@ """Test shopping list component.""" from http import HTTPStatus +import json +from pathlib import Path import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.shopping_list import NoMatchingShoppingListItem from homeassistant.components.shopping_list.const import ( @@ -28,7 +31,23 @@ from tests.common import async_capture_events from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_add_item(hass: HomeAssistant, sl_setup) -> None: +def assert_shopping_list_data(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Assert shopping list data matches snapshot.""" + path = Path(hass.config.path(".shopping_list.json")) + if not path.exists(): + assert snapshot == "" + else: + shopping_list_data = json.loads(path.read_text(encoding="utf-8")) + for item in shopping_list_data: + if "id" not in item: + continue + item["id"] = "" + assert shopping_list_data == snapshot + + +async def test_add_item( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test adding an item intent.""" response = await intent.async_handle( @@ -39,10 +58,13 @@ async def test_add_item(hass: HomeAssistant, sl_setup) -> None: # Response text is now handled by default conversation agent assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert_shopping_list_data(hass, snapshot) -async def test_remove_item(hass: HomeAssistant, sl_setup) -> None: - """Test removiung list items.""" +async def test_remove_item( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: + """Test removing list items.""" await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) @@ -50,12 +72,14 @@ async def test_remove_item(hass: HomeAssistant, sl_setup) -> None: await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} ) + assert_shopping_list_data(hass, snapshot) assert len(hass.data[DOMAIN].items) == 2 # Remove a single item item_id = hass.data[DOMAIN].items[0]["id"] await hass.data[DOMAIN].async_remove(item_id) + assert_shopping_list_data(hass, snapshot) assert len(hass.data[DOMAIN].items) == 1 @@ -65,9 +89,12 @@ async def test_remove_item(hass: HomeAssistant, sl_setup) -> None: # Trying to remove the same item twice should fail with pytest.raises(NoMatchingShoppingListItem): await hass.data[DOMAIN].async_remove(item_id) + assert_shopping_list_data(hass, snapshot) -async def test_update_list(hass: HomeAssistant, sl_setup) -> None: +async def test_update_list( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test updating all list items.""" await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} @@ -76,6 +103,7 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None: await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} ) + assert_shopping_list_data(hass, snapshot) # Update a single attribute, other attributes shouldn't change await hass.data[DOMAIN].async_update_list({"complete": True}) @@ -90,6 +118,7 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None: # Update multiple attributes await hass.data[DOMAIN].async_update_list({"name": "dupe", "complete": False}) + assert_shopping_list_data(hass, snapshot) beer = hass.data[DOMAIN].items[0] assert beer["name"] == "dupe" @@ -100,7 +129,9 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None: assert cheese["complete"] is False -async def test_clear_completed_items(hass: HomeAssistant, sl_setup) -> None: +async def test_clear_completed_items( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test clear completed list items.""" await intent.async_handle( hass, @@ -112,18 +143,23 @@ async def test_clear_completed_items(hass: HomeAssistant, sl_setup) -> None: await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} ) + assert_shopping_list_data(hass, snapshot) assert len(hass.data[DOMAIN].items) == 2 # Update a single attribute, other attributes shouldn't change await hass.data[DOMAIN].async_update_list({"complete": True}) + assert_shopping_list_data(hass, snapshot) await hass.data[DOMAIN].async_clear_completed() + assert_shopping_list_data(hass, snapshot) assert len(hass.data[DOMAIN].items) == 0 -async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None: +async def test_recent_items_intent( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test recent items.""" await intent.async_handle( @@ -135,8 +171,10 @@ async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None: await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}} ) + assert_shopping_list_data(hass, snapshot) response = await intent.async_handle(hass, "test", "HassShoppingListLastItems") + assert_shopping_list_data(hass, snapshot) assert ( response.speech["plain"]["speech"] @@ -145,7 +183,10 @@ async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None: async def test_deprecated_api_get_all( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" @@ -155,9 +196,11 @@ async def test_deprecated_api_get_all( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) client = await hass_client() resp = await client.get("/api/shopping_list") + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -169,7 +212,10 @@ async def test_deprecated_api_get_all( async def test_ws_get_items( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test get shopping_list items websocket command.""" @@ -179,6 +225,7 @@ async def test_ws_get_items( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -187,6 +234,7 @@ async def test_ws_get_items( msg = await client.receive_json() assert msg["success"] is True assert len(events) == 0 + assert_shopping_list_data(hass, snapshot) assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT @@ -200,7 +248,10 @@ async def test_ws_get_items( async def test_deprecated_api_update( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" @@ -210,6 +261,7 @@ async def test_deprecated_api_update( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] @@ -219,6 +271,7 @@ async def test_deprecated_api_update( resp = await client.post( f"/api/shopping_list/item/{beer_id}", json={"name": "soda"} ) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.OK assert len(events) == 1 @@ -228,6 +281,7 @@ async def test_deprecated_api_update( resp = await client.post( f"/api/shopping_list/item/{wine_id}", json={"complete": True} ) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.OK assert len(events) == 2 @@ -240,7 +294,10 @@ async def test_deprecated_api_update( async def test_ws_update_item( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test update shopping_list item websocket command.""" await intent.async_handle( @@ -249,6 +306,7 @@ async def test_ws_update_item( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] @@ -267,6 +325,7 @@ async def test_ws_update_item( data = msg["result"] assert data == {"id": beer_id, "name": "soda", "complete": False} assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) await client.send_json( { @@ -281,6 +340,7 @@ async def test_ws_update_item( data = msg["result"] assert data == {"id": wine_id, "name": "wine", "complete": True} assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) beer, wine = hass.data["shopping_list"].items assert beer == {"id": beer_id, "name": "soda", "complete": False} @@ -288,34 +348,44 @@ async def test_ws_update_item( async def test_api_update_fails( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) + assert_shopping_list_data(hass, snapshot) client = await hass_client() events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) resp = await client.post("/api/shopping_list/non_existing", json={"name": "soda"}) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.NOT_FOUND assert len(events) == 0 beer_id = hass.data["shopping_list"].items[0]["id"] resp = await client.post(f"/api/shopping_list/item/{beer_id}", json={"name": 123}) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.BAD_REQUEST async def test_ws_update_item_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test failure of update shopping_list item websocket command.""" await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) + assert_shopping_list_data(hass, snapshot) client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await client.send_json( @@ -331,15 +401,20 @@ async def test_ws_update_item_fail( data = msg["error"] assert data == {"code": "item_not_found", "message": "Item not found"} assert len(events) == 0 + assert_shopping_list_data(hass, snapshot) await client.send_json({"id": 6, "type": "shopping_list/items/update", "name": 123}) msg = await client.receive_json() assert msg["success"] is False assert len(events) == 0 + assert_shopping_list_data(hass, snapshot) async def test_deprecated_api_clear_completed( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" @@ -349,6 +424,7 @@ async def test_deprecated_api_clear_completed( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] @@ -362,10 +438,12 @@ async def test_deprecated_api_clear_completed( ) assert resp.status == HTTPStatus.OK assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) resp = await client.post("/api/shopping_list/clear_completed") assert resp.status == HTTPStatus.OK assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) items = hass.data["shopping_list"].items assert len(items) == 1 @@ -374,7 +452,10 @@ async def test_deprecated_api_clear_completed( async def test_ws_clear_items( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test clearing shopping_list items websocket command.""" await intent.async_handle( @@ -383,6 +464,7 @@ async def test_ws_clear_items( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] client = await hass_ws_client(hass) @@ -398,6 +480,7 @@ async def test_ws_clear_items( msg = await client.receive_json() assert msg["success"] is True assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) await client.send_json({"id": 6, "type": "shopping_list/items/clear"}) msg = await client.receive_json() @@ -406,16 +489,21 @@ async def test_ws_clear_items( assert len(items) == 1 assert items[0] == {"id": wine_id, "name": "wine", "complete": False} assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) async def test_deprecated_api_create( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" client = await hass_client() events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) resp = await client.post("/api/shopping_list/item", json={"name": "soda"}) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -430,13 +518,17 @@ async def test_deprecated_api_create( async def test_deprecated_api_create_fail( - hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test the API.""" client = await hass_client() events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) resp = await client.post("/api/shopping_list/item", json={"name": 1234}) + assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.BAD_REQUEST assert len(hass.data["shopping_list"].items) == 0 @@ -444,7 +536,10 @@ async def test_deprecated_api_create_fail( async def test_ws_add_item( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test adding shopping_list item websocket command.""" client = await hass_ws_client(hass) @@ -456,6 +551,7 @@ async def test_ws_add_item( assert data["name"] == "soda" assert data["complete"] is False assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) items = hass.data["shopping_list"].items assert len(items) == 1 @@ -464,7 +560,10 @@ async def test_ws_add_item( async def test_ws_add_item_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test adding shopping_list item failure websocket command.""" client = await hass_ws_client(hass) @@ -474,10 +573,14 @@ async def test_ws_add_item_fail( assert msg["success"] is False assert len(events) == 0 assert len(hass.data["shopping_list"].items) == 0 + assert_shopping_list_data(hass, snapshot) async def test_ws_remove_item( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test removing shopping_list item websocket command.""" client = await hass_ws_client(hass) @@ -490,6 +593,7 @@ async def test_ws_remove_item( ) msg = await client.receive_json() assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) items = hass.data["shopping_list"].items assert len(items) == 2 @@ -500,6 +604,7 @@ async def test_ws_remove_item( msg = await client.receive_json() assert len(events) == 3 assert msg["success"] is True + assert_shopping_list_data(hass, snapshot) items = hass.data["shopping_list"].items assert len(items) == 1 @@ -507,22 +612,30 @@ async def test_ws_remove_item( async def test_ws_remove_item_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test removing shopping_list item failure websocket command.""" client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await client.send_json({"id": 5, "type": "shopping_list/items/add", "name": "soda"}) msg = await client.receive_json() + assert_shopping_list_data(hass, snapshot) await client.send_json({"id": 6, "type": "shopping_list/items/remove"}) msg = await client.receive_json() assert msg["success"] is False assert len(events) == 1 assert len(hass.data["shopping_list"].items) == 1 + assert_shopping_list_data(hass, snapshot) async def test_ws_reorder_items( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test reordering shopping_list items websocket command.""" await intent.async_handle( @@ -534,6 +647,7 @@ async def test_ws_reorder_items( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "apple"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] @@ -566,6 +680,7 @@ async def test_ws_reorder_items( "name": "beer", "complete": False, } + assert_shopping_list_data(hass, snapshot) # Mark wine as completed. await client.send_json( @@ -578,6 +693,7 @@ async def test_ws_reorder_items( ) _ = await client.receive_json() assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) await client.send_json( { @@ -604,10 +720,14 @@ async def test_ws_reorder_items( "name": "wine", "complete": True, } + assert_shopping_list_data(hass, snapshot) async def test_ws_reorder_items_failure( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + snapshot: SnapshotAssertion, ) -> None: """Test reordering shopping_list items websocket command.""" await intent.async_handle( @@ -619,6 +739,7 @@ async def test_ws_reorder_items_failure( await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "apple"}} ) + assert_shopping_list_data(hass, snapshot) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] @@ -639,6 +760,7 @@ async def test_ws_reorder_items_failure( assert msg["success"] is False assert msg["error"]["code"] == ERR_NOT_FOUND assert len(events) == 0 + assert_shopping_list_data(hass, snapshot) # Testing not sending all unchecked item ids. await client.send_json( @@ -652,9 +774,12 @@ async def test_ws_reorder_items_failure( assert msg["success"] is False assert msg["error"]["code"] == ERR_INVALID_FORMAT assert len(events) == 0 + assert_shopping_list_data(hass, snapshot) -async def test_add_item_service(hass: HomeAssistant, sl_setup) -> None: +async def test_add_item_service( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test adding shopping_list item service.""" events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await hass.services.async_call( @@ -665,9 +790,12 @@ async def test_add_item_service(hass: HomeAssistant, sl_setup) -> None: ) assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) -async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None: +async def test_remove_item_service( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test removing shopping_list item service.""" events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await hass.services.async_call( @@ -684,6 +812,7 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None: ) assert len(hass.data[DOMAIN].items) == 2 assert len(events) == 2 + assert_shopping_list_data(hass, snapshot) await hass.services.async_call( DOMAIN, @@ -694,9 +823,12 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None: assert len(hass.data[DOMAIN].items) == 1 assert hass.data[DOMAIN].items[0]["name"] == "cheese" assert len(events) == 3 + assert_shopping_list_data(hass, snapshot) -async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> None: +async def test_clear_completed_items_service( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test clearing completed shopping_list items service.""" events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await hass.services.async_call( @@ -707,6 +839,7 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N ) assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await hass.services.async_call( @@ -717,6 +850,7 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N ) assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await hass.services.async_call( @@ -727,9 +861,12 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N ) assert len(hass.data[DOMAIN].items) == 0 assert len(events) == 1 + assert_shopping_list_data(hass, snapshot) -async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None: +async def test_sort_list_service( + hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion +) -> None: """Test sort_all service.""" for name in ("zzz", "ddd", "aaa"): @@ -739,6 +876,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_NAME: name}, blocking=True, ) + assert_shopping_list_data(hass, snapshot) # sort ascending events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -748,6 +886,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_REVERSE: False}, blocking=True, ) + assert_shopping_list_data(hass, snapshot) assert hass.data[DOMAIN].items[0][ATTR_NAME] == "aaa" assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" @@ -761,6 +900,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_REVERSE: True}, blocking=True, ) + assert_shopping_list_data(hass, snapshot) assert hass.data[DOMAIN].items[0][ATTR_NAME] == "zzz" assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" From 5c4f99b828d57723649ec0bc5eb89c59aad857e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 Jan 2026 16:46:27 -1000 Subject: [PATCH 049/163] Bump aiohttp 3.13.3 (#160206) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/shopping_list/conftest.py | 22 +++++++++++++--------- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8b35de56a2c..8cf3927516f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.2 +aiohttp==3.13.3 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index a00cbf9f04d..9a225c242f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.3", - "aiohttp==3.13.2", + "aiohttp==3.13.3", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index bc7d83e3f22..84da49c2b3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.2 +aiohttp==3.13.3 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index 69d214efe18..f1efbfe6ab7 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -1,25 +1,29 @@ """Shopping list test helpers.""" from collections.abc import Generator -from contextlib import suppress -import os +from pathlib import Path +from unittest.mock import patch import pytest -from homeassistant.components.shopping_list import intent as sl_intent +from homeassistant.components.shopping_list import PERSISTENCE, intent as sl_intent from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def wipe_shopping_list_store(hass: HomeAssistant) -> Generator[None]: - """Wipe shopping list store after test.""" - try: +def shopping_list_tmp_path(tmp_path: Path, hass: HomeAssistant) -> Generator[None]: + """Use a unique temp directory for shopping list storage per test.""" + orig_path = hass.config.path + + def _mock_path(*args: str) -> str: + if args == (PERSISTENCE,): + return str(tmp_path / PERSISTENCE) + return orig_path(*args) + + with patch.object(hass.config, "path", _mock_path): yield - finally: - with suppress(FileNotFoundError): - os.remove(hass.config.path(".shopping_list.json")) @pytest.fixture From 358ad29b59c2056f293b958e6a4d98bbd3d5b25c Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:36:24 +0800 Subject: [PATCH 050/163] Switchbot Cloud: Fixed Robot Vacuum Cleaner S20 had two device_model name (#160230) --- homeassistant/components/switchbot_cloud/__init__.py | 1 + homeassistant/components/switchbot_cloud/vacuum.py | 5 ++++- tests/components/switchbot_cloud/test_vacuum.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 4573d04e2a0..2676323c99c 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -171,6 +171,7 @@ async def make_device_data( "K20+ Pro", "Robot Vacuum Cleaner K10+ Pro Combo", "Robot Vacuum Cleaner S10", + "Robot Vacuum Cleaner S20", "S20", "Robot Vacuum Cleaner K11 Plus", ]: diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index cc1f4c13e0b..a3514f1bee6 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -245,6 +245,9 @@ def _async_make_entity( return SwitchBotCloudVacuumV2(api, device, coordinator) if device.device_type == "Robot Vacuum Cleaner K10+ Pro Combo": return SwitchBotCloudVacuumK10PlusProCombo(api, device, coordinator) - if device.device_type in VacuumCleanerV3Commands.get_supported_devices(): + if ( + device.device_type in VacuumCleanerV3Commands.get_supported_devices() + or device.device_type == "Robot Vacuum Cleaner S20" + ): return SwitchBotCloudVacuumV3(api, device, coordinator) return SwitchBotCloudVacuum(api, device, coordinator) diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py index daa52f4f183..86929c6c49f 100644 --- a/tests/components/switchbot_cloud/test_vacuum.py +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -476,7 +476,7 @@ async def test_s20_start( ) -async def test_s20set_fan_speed( +async def test_s20_set_fan_speed( hass: HomeAssistant, mock_list_devices, mock_get_status ) -> None: """Test s20 set fan speed.""" From 8d05a5f3d4a4ee350a1552ea1d45a23d453c2cbf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 4 Jan 2026 11:37:41 +0100 Subject: [PATCH 051/163] Bump aiowebdav2 to 0.5.0 (#160233) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 9e9e1c8866e..29559bfc186 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.6"] + "requirements": ["aiowebdav2==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf3eccab3f3..a63726551ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -441,7 +441,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.6 +aiowebdav2==0.5.0 # homeassistant.components.webostv aiowebostv==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50492002af1..61681f6b119 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -426,7 +426,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.6 +aiowebdav2==0.5.0 # homeassistant.components.webostv aiowebostv==0.7.5 From 15b0342bd736eb7b769325da60c1bd5e6d823d64 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:27:57 +0100 Subject: [PATCH 052/163] Fix Tuya light color data wrapper (#160280) --- homeassistant/components/tuya/light.py | 10 +++++----- tests/components/tuya/test_light.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index aef9908c825..d070ffd038a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -186,14 +186,14 @@ class _ColorDataWrapper(DPCodeJsonWrapper): ) def _convert_value_to_raw_value( - self, device: CustomerDevice, value: tuple[tuple[float, float], float] + self, device: CustomerDevice, value: tuple[float, float, float] ) -> Any: - """Convert a Home Assistant color/brightness pair back to a raw device value.""" - color, brightness = value + """Convert a Home Assistant tuple (H, S, V) back to a raw device value.""" + hue, saturation, brightness = value return json.dumps( { - "h": round(self.h_type.remap_value_from(color[0])), - "s": round(self.s_type.remap_value_from(color[1])), + "h": round(self.h_type.remap_value_from(hue)), + "s": round(self.s_type.remap_value_from(saturation)), "v": round(self.v_type.remap_value_from(brightness)), } ) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 37d70150d11..d0b541a0231 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -11,6 +11,7 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_HS_COLOR, ATTR_WHITE, DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -91,6 +92,18 @@ async def test_platform_setup_and_discovery( {"code": "bright_value_v2", "value": 592}, ], ), + ( + SERVICE_TURN_ON, + { + ATTR_BRIGHTNESS: 255, + ATTR_HS_COLOR: (10.1, 20.2), + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "colour"}, + {"code": "colour_data_v2", "value": '{"h": 10, "s": 202, "v": 1000}'}, + ], + ), ( SERVICE_TURN_OFF, {}, From e560795d04099b4adb6d05f3e6f9ba6cfa2e3695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 5 Jan 2026 15:35:49 +0100 Subject: [PATCH 053/163] Add connection check before registering cloudhook URL (#160284) --- .../components/mobile_app/http_api.py | 2 +- tests/components/mobile_app/test_http_api.py | 62 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index f4786b2914c..7acf3cfdd71 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -69,7 +69,7 @@ class RegistrationsView(HomeAssistantView): webhook_id = secrets.token_hex() - if cloud.async_active_subscription(hass): + if cloud.async_active_subscription(hass) and cloud.async_is_connected(hass): data[CONF_CLOUDHOOK_URL] = await async_create_cloud_hook( hass, webhook_id, None ) diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index b333f91d985..3c82f4f4d6f 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -7,8 +7,13 @@ from unittest.mock import patch from nacl.encoding import Base64Encoder from nacl.secret import SecretBox +import pytest -from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN +from homeassistant.components.mobile_app.const import ( + CONF_CLOUDHOOK_URL, + CONF_SECRET, + DOMAIN, +) from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -101,6 +106,61 @@ async def test_registration_encryption( assert json.loads(decrypted_data) == {"one": "Hello world"} +@pytest.mark.parametrize( + "cloud_is_connected", + [ + True, + False, + ], +) +async def test_registration_with_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + cloud_is_connected: bool, +) -> None: + """Test that cloudhook_url is only returned when cloud is connected.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + api_client = await hass_client() + + cloudhook_url = "https://hooks.nabu.casa/test123" + + with ( + patch( + "homeassistant.components.mobile_app.http_api.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.mobile_app.http_api.cloud.async_is_connected", + return_value=cloud_is_connected, + ), + patch( + "homeassistant.components.mobile_app.http_api.async_create_cloud_hook", + return_value=cloudhook_url, + ), + patch( + "homeassistant.components.mobile_app.http_api.cloud.async_remote_ui_url", + return_value="https://remote.ui", + ), + patch( + "homeassistant.components.person.async_add_user_device_tracker", + spec=True, + ), + ): + resp = await api_client.post( + "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT + ) + + assert resp.status == HTTPStatus.CREATED + register_json = await resp.json() + assert CONF_WEBHOOK_ID in register_json + + assert register_json.get(CONF_CLOUDHOOK_URL) == ( + cloudhook_url if cloud_is_connected else None + ) + + async def test_registration_encryption_legacy( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From e5e7546d49d50dc13b4a6f5d7a13afb6b1e58a5b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Jan 2026 13:45:31 +0100 Subject: [PATCH 054/163] Fix humidifier trigger turned on icon (#160297) --- homeassistant/components/humidifier/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 1788a9395d3..4544b2bfb68 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -66,7 +66,7 @@ "trigger": "mdi:air-humidifier-off" }, "turned_on": { - "trigger": "mdi:air-humidifier-on" + "trigger": "mdi:air-humidifier" } } } From 4d55939f533c2d6b303aa7d1cc8af787440f1283 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Jan 2026 16:53:53 +0100 Subject: [PATCH 055/163] Bump version to 2026.1.0b3 --- 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 4b04799f4a7..c571353ae7f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __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 9a225c242f1..5d99c7aaf49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b2" +version = "2026.1.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 7e013b723d836d03c6613ff0b0610ad310ee0618 Mon Sep 17 00:00:00 2001 From: Xidorn Quan Date: Tue, 6 Jan 2026 06:02:21 +1000 Subject: [PATCH 056/163] Fix rain count sensors' state class of Ecowitt (#158204) --- homeassistant/components/ecowitt/sensor.py | 54 ++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 5044dd44155..296490511cb 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses from datetime import datetime +import logging from typing import Final from aioecowitt import EcoWittSensor, EcoWittSensorTypes @@ -39,6 +40,9 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from . import EcowittConfigEntry from .entity import EcowittEntity +_LOGGER = logging.getLogger(__name__) + + _METRIC: Final = ( EcoWittSensorTypes.TEMPERATURE_C, EcoWittSensorTypes.RAIN_COUNT_MM, @@ -57,6 +61,40 @@ _IMPERIAL: Final = ( ) +_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = { + "eventrainin": SensorStateClass.TOTAL_INCREASING, + "hourlyrainin": None, + "totalrainin": SensorStateClass.TOTAL_INCREASING, + "dailyrainin": SensorStateClass.TOTAL_INCREASING, + "weeklyrainin": SensorStateClass.TOTAL_INCREASING, + "monthlyrainin": SensorStateClass.TOTAL_INCREASING, + "yearlyrainin": SensorStateClass.TOTAL_INCREASING, + "last24hrainin": None, + "eventrainmm": SensorStateClass.TOTAL_INCREASING, + "hourlyrainmm": None, + "totalrainmm": SensorStateClass.TOTAL_INCREASING, + "dailyrainmm": SensorStateClass.TOTAL_INCREASING, + "weeklyrainmm": SensorStateClass.TOTAL_INCREASING, + "monthlyrainmm": SensorStateClass.TOTAL_INCREASING, + "yearlyrainmm": SensorStateClass.TOTAL_INCREASING, + "last24hrainmm": None, + "erain_piezo": SensorStateClass.TOTAL_INCREASING, + "hrain_piezo": None, + "drain_piezo": SensorStateClass.TOTAL_INCREASING, + "wrain_piezo": SensorStateClass.TOTAL_INCREASING, + "mrain_piezo": SensorStateClass.TOTAL_INCREASING, + "yrain_piezo": SensorStateClass.TOTAL_INCREASING, + "last24hrain_piezo": None, + "erain_piezomm": SensorStateClass.TOTAL_INCREASING, + "hrain_piezomm": None, + "drain_piezomm": SensorStateClass.TOTAL_INCREASING, + "wrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "mrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "yrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "last24hrain_piezomm": None, +} + + ECOWITT_SENSORS_MAPPING: Final = { EcoWittSensorTypes.HUMIDITY: SensorEntityDescription( key="HUMIDITY", @@ -285,15 +323,15 @@ async def async_setup_entry( name=sensor.name, ) - # Only total rain needs state class for long-term statistics - if sensor.key in ( - "totalrainin", - "totalrainmm", + if sensor.stype in ( + EcoWittSensorTypes.RAIN_COUNT_INCHES, + EcoWittSensorTypes.RAIN_COUNT_MM, ): - description = dataclasses.replace( - description, - state_class=SensorStateClass.TOTAL_INCREASING, - ) + if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: + _LOGGER.warning("Unknown rain count sensor: %s", sensor.key) + return + state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key] + description = dataclasses.replace(description, state_class=state_class) async_add_entities([EcowittSensorEntity(sensor, description)]) From 242be14f8878305eec4b97acb7cf59b197f86a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric?= Date: Mon, 5 Jan 2026 15:24:20 -0800 Subject: [PATCH 057/163] Add Resideo X2S Smart Thermostat to Matter fan-only mode list (#160260) --- homeassistant/components/matter/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index d9fd62fa210..5eec7c0ba77 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -154,6 +154,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { (0x1209, 0x8027), (0x1209, 0x8028), (0x1209, 0x8029), + (0x131A, 0x1000), } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum From eea1adccfd47ad4cdb5b62506cc3509a0d2b72df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 5 Jan 2026 21:27:00 +0100 Subject: [PATCH 058/163] Fix unit for Tibber sensor (#160319) --- homeassistant/components/tibber/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 857f01c6a6a..a5dca4bbafa 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -281,7 +281,7 @@ DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = ( key="range.remaining", translation_key="range_remaining", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.METERS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), From 86257b1865cbf19cf42a0b525c3145d171c74344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jan 2026 08:47:31 -1000 Subject: [PATCH 059/163] Require service_uuid and service_data_uuid to match hue ble (#160321) --- homeassistant/components/hue_ble/manifest.json | 3 ++- homeassistant/generated/bluetooth.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index ce3b4b4e585..707594fcde1 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -4,7 +4,8 @@ "bluetooth": [ { "connectable": true, - "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb" + "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", + "service_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb" } ], "codeowners": ["@flip-dots"], diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index f212b6aadb4..51709a3b548 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -323,6 +323,7 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "connectable": True, "domain": "hue_ble", "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", + "service_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", }, { "connectable": True, From 340c2e48df6d1e5ec1f158d819b0dbba4ab4b575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 6 Jan 2026 00:55:28 +0100 Subject: [PATCH 060/163] Bump pyTibber to 0.34.0 (#160333) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 3e8e0246f1c..61cd5e325e5 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.33.1"] + "requirements": ["pyTibber==0.34.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a63726551ee..db84b770323 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.33.1 +pyTibber==0.34.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61681f6b119..5b46239bb36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.33.1 +pyTibber==0.34.0 # homeassistant.components.dlink pyW215==0.8.0 From 5d7b10f569dde630aae7b72a20d4f56fbf3bba45 Mon Sep 17 00:00:00 2001 From: Mika Date: Tue, 6 Jan 2026 12:36:49 +0100 Subject: [PATCH 061/163] Fix missing state class to solaredge (#160336) --- homeassistant/components/solaredge/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 6ae180ed823..b56c35be160 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -46,7 +46,7 @@ SENSOR_TYPES = [ key="lifetime_energy", json_key="lifeTimeData", translation_key="lifetime_energy", - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -55,6 +55,7 @@ SENSOR_TYPES = [ json_key="lastYearData", translation_key="energy_this_year", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -63,6 +64,7 @@ SENSOR_TYPES = [ json_key="lastMonthData", translation_key="energy_this_month", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -71,6 +73,7 @@ SENSOR_TYPES = [ json_key="lastDayData", translation_key="energy_today", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -123,24 +126,32 @@ SENSOR_TYPES = [ json_key="LOAD", translation_key="power_consumption", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, ), SolarEdgeSensorEntityDescription( key="solar_power", json_key="PV", translation_key="solar_power", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, ), SolarEdgeSensorEntityDescription( key="grid_power", json_key="GRID", translation_key="grid_power", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, ), SolarEdgeSensorEntityDescription( key="storage_power", json_key="STORAGE", translation_key="storage_power", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, ), SolarEdgeSensorEntityDescription( key="purchased_energy", @@ -194,6 +205,7 @@ SENSOR_TYPES = [ entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, ), ] From 4afe67f33d794faa67fc5cc26058c042dd6b6726 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 6 Jan 2026 01:25:43 -0800 Subject: [PATCH 062/163] Bump opower to 0.16.0 (#160348) --- 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 36855eb6bb7..7bc105c3474 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.15.9"] + "requirements": ["opower==0.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index db84b770323..01d5fa1b501 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.9 +opower==0.16.0 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b46239bb36..a77fa49877a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.9 +opower==0.16.0 # homeassistant.components.oralb oralb-ble==1.0.2 From eb6582bc2448ed66eecbde7b4cb7c9f1c0a3d8ac Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Jan 2026 17:23:24 +0100 Subject: [PATCH 063/163] Fix number or entity choose schema (#160358) --- homeassistant/helpers/trigger.py | 4 ++-- tests/helpers/test_trigger.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 8263e4ba284..bffc01ecfd7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -537,7 +537,7 @@ def _validate_range[_T: dict[str, Any]]( _NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema( { - vol.Required("chosen_selector"): vol.In(["number", "entity"]), + vol.Required("active_choice"): vol.In(["number", "entity"]), vol.Optional("entity"): cv.entity_id, vol.Optional("number"): vol.Coerce(float), } @@ -548,7 +548,7 @@ def _validate_number_or_entity(value: dict | float | str) -> float | str: """Validate number or entity selector result.""" if isinstance(value, dict): _NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value) - return value[value["chosen_selector"]] # type: ignore[no-any-return] + return value[value["active_choice"]] # type: ignore[no-any-return] return value diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index ad6367e23de..0d1400bcbd1 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1207,19 +1207,19 @@ async def test_subscribe_triggers_no_triggers( ), # Test verbose choose selector options ( - {CONF_ABOVE: {"chosen_selector": "entity", "entity": "sensor.test"}}, + {CONF_ABOVE: {"active_choice": "entity", "entity": "sensor.test"}}, does_not_raise(), ), ( - {CONF_ABOVE: {"chosen_selector": "number", "number": 10}}, + {CONF_ABOVE: {"active_choice": "number", "number": 10}}, does_not_raise(), ), ( - {CONF_BELOW: {"chosen_selector": "entity", "entity": "sensor.test"}}, + {CONF_BELOW: {"active_choice": "entity", "entity": "sensor.test"}}, does_not_raise(), ), ( - {CONF_BELOW: {"chosen_selector": "number", "number": 90}}, + {CONF_BELOW: {"active_choice": "number", "number": 90}}, does_not_raise(), ), # Test invalid configurations @@ -1235,7 +1235,7 @@ async def test_subscribe_triggers_no_triggers( ), ( # Invalid choose selector option - {CONF_BELOW: {"chosen_selector": "cat", "cat": 90}}, + {CONF_BELOW: {"active_choice": "cat", "cat": 90}}, pytest.raises(vol.Invalid), ), ], From 14f1d9fbad088e430cd8c33f52ee89dab45c954b Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Tue, 6 Jan 2026 18:42:58 +0300 Subject: [PATCH 064/163] Bump pybravia to 0.4.1 (#160368) --- homeassistant/components/braviatv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 15ab864990f..194b14512e7 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.4"], + "requirements": ["pybravia==0.4.1"], "ssdp": [ { "manufacturer": "Sony Corporation", diff --git a/requirements_all.txt b/requirements_all.txt index 01d5fa1b501..05ad808fc0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1931,7 +1931,7 @@ pyblu==2.0.5 pybotvac==0.0.28 # homeassistant.components.braviatv -pybravia==0.3.4 +pybravia==0.4.1 # homeassistant.components.nissan_leaf pycarwings2==2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a77fa49877a..b4ce359890f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1650,7 +1650,7 @@ pyblu==2.0.5 pybotvac==0.0.28 # homeassistant.components.braviatv -pybravia==0.3.4 +pybravia==0.4.1 # homeassistant.components.cloudflare pycfdns==3.0.0 From 8e55ceea77aa34d5b3d766ec8c5afd4c24cd67bf Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Jan 2026 17:53:56 +0100 Subject: [PATCH 065/163] Update frontend to 20251229.1 (#160372) --- 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 ec35b9ddddd..8047d3378a7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -23,5 +23,5 @@ "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20251229.0"] + "requirements": ["home-assistant-frontend==20251229.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8cf3927516f..424869e9bd2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.8.0 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20251229.0 +home-assistant-frontend==20251229.1 home-assistant-intents==2026.1.1 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 05ad808fc0e..4622ea65ac2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251229.0 +home-assistant-frontend==20251229.1 # homeassistant.components.conversation home-assistant-intents==2026.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4ce359890f..5a5ee507221 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251229.0 +home-assistant-frontend==20251229.1 # homeassistant.components.conversation home-assistant-intents==2026.1.1 From 288a805d0f4a4c03c77c9288ebcfa4eafc2a70e4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Jan 2026 17:56:49 +0100 Subject: [PATCH 066/163] Bump version to 2026.1.0b4 --- 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 c571353ae7f..2714c3634a1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __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 5d99c7aaf49..d6eb1df2371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b3" +version = "2026.1.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From d96da9a6394e101bc7a81c6596533e062a469b6b Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Wed, 7 Jan 2026 00:14:05 -1000 Subject: [PATCH 067/163] Fix Ring integration log flooding for accounts without subscription (#158012) Co-authored-by: Robert Resch --- homeassistant/components/ring/camera.py | 13 +++++++-- homeassistant/components/ring/strings.json | 3 ++ tests/components/ring/test_camera.py | 32 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 156d82665d2..ee4ab050aca 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera): self._device = self._get_coordinator_data().get_video_device( self._device.device_api_id ) + history_data = self._device.last_history - if history_data: + if history_data and self._device.has_subscription: self._last_event = history_data[0] # will call async_update to update the attributes and get the # video url from the api @@ -154,8 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" + if self._video_url is None: + if not self._device.has_subscription: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_subscription", + ) + return None + key = (width, height) - if not (image := self._images.get(key)) and self._video_url is not None: + if not (image := self._images.get(key)): image = await ffmpeg.async_get_image( self.hass, self._video_url, diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 09f36d6dd74..1159a8b906e 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -151,6 +151,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "no_subscription": { + "message": "Ring Protect subscription required for snapshots" + }, "sdp_m_line_index_required": { "message": "Error negotiating stream for {device}" } diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 54638df9a46..95ee0d4b5fd 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -325,6 +325,38 @@ async def test_camera_image( assert image.content == SMALLEST_VALID_JPEG_BYTES +async def test_camera_live_view_no_subscription( + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test live view camera skips recording URL when no subscription.""" + await setup_platform(hass, Platform.CAMERA) + + front_camera_mock = mock_ring_devices.get_device(765432) + # Set device to not have subscription + front_camera_mock.has_subscription = False + + state = hass.states.get("camera.front_live_view") + assert state is not None + + # Reset mock call counts + front_camera_mock.async_recording_url.reset_mock() + + # Trigger coordinator update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # For cameras without subscription, recording URL should NOT be fetched + front_camera_mock.async_recording_url.assert_not_called() + + # Requesting an image without subscription should raise an error + with pytest.raises(HomeAssistantError): + await async_get_image(hass, "camera.front_live_view") + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_stream_attributes( hass: HomeAssistant, From 5d4262e8b3886adde316ec5a79799770eedb1d9c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 6 Jan 2026 18:11:40 +0100 Subject: [PATCH 068/163] Bump ZHA to 0.0.83 (#160342) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a004ed340ae..7bb80c3f4ab 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==0.0.82", "serialx==0.5.0"], + "requirements": ["zha==0.0.83", "serialx==0.5.0"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 4622ea65ac2..4961e9b7098 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3277,7 +3277,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.82 +zha==0.0.83 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a5ee507221..d7d07a5cca9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2738,7 +2738,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.82 +zha==0.0.83 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From 771292ced93a57ea23a87fd1662c499e92f720f2 Mon Sep 17 00:00:00 2001 From: Xiangxuan Qu Date: Wed, 7 Jan 2026 20:22:39 +0900 Subject: [PATCH 069/163] Fix IndexError in Israel Rail sensor when no departures available (#160351) Co-authored-by: Joostlek --- .../components/israel_rail/sensor.py | 2 + tests/components/israel_rail/test_sensor.py | 42 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py index 86b2e135cfb..6e3324de7ae 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -116,6 +116,8 @@ class IsraelRailEntitySensor( @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" + if self.entity_description.index >= len(self.coordinator.data): + return None return self.entity_description.value_fn( self.coordinator.data[self.entity_description.index] ) diff --git a/tests/components/israel_rail/test_sensor.py b/tests/components/israel_rail/test_sensor.py index 08aed2bbc21..a1bf00472d9 100644 --- a/tests/components/israel_rail/test_sensor.py +++ b/tests/components/israel_rail/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -66,3 +66,43 @@ async def test_fail_query( assert len(hass.states.async_entity_ids()) == 6 departure_sensor = hass.states.get("sensor.mock_title_departure") assert departure_sensor.state == STATE_UNAVAILABLE + + +async def test_no_departures( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling when there are no departures available.""" + await init_integration(hass, mock_config_entry) + assert len(hass.states.async_entity_ids()) == 6 + + # Simulate no departures (e.g., after-hours) + mock_israelrail.query.return_value = [] + + await goto_future(hass, freezer) + + # All sensors should still exist + assert len(hass.states.async_entity_ids()) == 6 + + # Departure sensors should have unknown state (None) + departure_sensor = hass.states.get("sensor.mock_title_departure") + assert departure_sensor.state == STATE_UNKNOWN + + departure_sensor_1 = hass.states.get("sensor.mock_title_departure_1") + assert departure_sensor_1.state == STATE_UNKNOWN + + departure_sensor_2 = hass.states.get("sensor.mock_title_departure_2") + assert departure_sensor_2.state == STATE_UNKNOWN + + # Non-departure sensors (platform, trains, train_number) also access index 0 + # and should have unknown state when no departures available + platform_sensor = hass.states.get("sensor.mock_title_platform") + assert platform_sensor.state == STATE_UNKNOWN + + trains_sensor = hass.states.get("sensor.mock_title_trains") + assert trains_sensor.state == STATE_UNKNOWN + + train_number_sensor = hass.states.get("sensor.mock_title_train_number") + assert train_number_sensor.state == STATE_UNKNOWN From ccbaac55b390d8f8b48e51450df92ad9de2b47ff Mon Sep 17 00:00:00 2001 From: hanwg Date: Wed, 7 Jan 2026 19:27:17 +0800 Subject: [PATCH 070/163] Fix schema validation error in Telegram (#160367) --- homeassistant/components/telegram/notify.py | 10 +- tests/components/telegram/test_notify.py | 111 +++++++++++++++++++- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index eff5d3b5635..c4a67f67b64 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -80,10 +80,6 @@ class TelegramNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)} - if ATTR_TITLE in kwargs: - service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)}) - if message: - service_data.update({ATTR_MESSAGE: message}) data = kwargs.get(ATTR_DATA) # Set message tag @@ -161,6 +157,12 @@ class TelegramNotificationService(BaseNotificationService): ) # Send message + + if ATTR_TITLE in kwargs: + service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)}) + if message: + service_data.update({ATTR_MESSAGE: message}) + _LOGGER.debug( "TELEGRAM NOTIFIER calling %s.send_message with %s", TELEGRAM_BOT_DOMAIN, diff --git a/tests/components/telegram/test_notify.py b/tests/components/telegram/test_notify.py index 67cfdd8b76a..5feaa6d6f93 100644 --- a/tests/components/telegram/test_notify.py +++ b/tests/components/telegram/test_notify.py @@ -1,12 +1,14 @@ """The tests for the telegram.notify platform.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, call, patch from homeassistant import config as hass_config from homeassistant.components import notify +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE from homeassistant.components.telegram import DOMAIN from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceRegistry from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -54,3 +56,108 @@ async def test_reload_notify( issue_id="migrate_notify", ) assert len(issue_registry.issues) == 1 + + +async def test_notify(hass: HomeAssistant) -> None: + """Test notify.""" + + assert await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: [ + { + "name": DOMAIN, + "platform": DOMAIN, + "chat_id": 1, + }, + ] + }, + ) + await hass.async_block_till_done() + + original_call = ServiceRegistry.async_call + with patch( + "homeassistant.core.ServiceRegistry.async_call", new_callable=AsyncMock + ) as mock_service_call: + # setup mock + + async def call_service(*args, **kwargs) -> Any: + if args[0] == notify.DOMAIN: + return await original_call( + hass.services, args[0], args[1], args[2], kwargs["blocking"] + ) + return AsyncMock() + + mock_service_call.side_effect = call_service + + # test send message + + data: dict[str, Any] = {"title": "mock title", "message": "mock message"} + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {ATTR_TITLE: "mock title", ATTR_MESSAGE: "mock message"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_service_call.mock_calls == [ + call( + "notify", + "telegram", + data, + blocking=True, + ), + call( + "telegram_bot", + "send_message", + {"target": 1, "title": "mock title", "message": "mock message"}, + False, + None, + None, + False, + ), + ] + + mock_service_call.reset_mock() + + # test send file + + data = { + ATTR_TITLE: "mock title", + ATTR_MESSAGE: "mock message", + ATTR_DATA: { + "photo": {"url": "https://mock/photo.jpg", "caption": "mock caption"} + }, + } + + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + data, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_service_call.mock_calls == [ + call( + "notify", + "telegram", + data, + blocking=True, + ), + call( + "telegram_bot", + "send_photo", + { + "target": 1, + "url": "https://mock/photo.jpg", + "caption": "mock caption", + }, + False, + None, + None, + False, + ), + ] From d3853019eb218bb570e2d8fbeea8e3e3d9f44e2e Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Wed, 7 Jan 2026 01:59:47 +0300 Subject: [PATCH 071/163] Add SSL support in Bravia TV (#160373) Co-authored-by: Maciej Bieniek --- homeassistant/components/braviatv/__init__.py | 4 +- .../components/braviatv/config_flow.py | 9 ++- homeassistant/components/braviatv/const.py | 1 + .../components/braviatv/strings.json | 5 +- tests/components/braviatv/test_config_flow.py | 77 +++++++------------ 5 files changed, 41 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 52fcf82b38b..1c183b397d8 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession +from .const import CONF_USE_SSL from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator PLATFORMS: Final[list[Platform]] = [ @@ -26,11 +27,12 @@ async def async_setup_entry( """Set up a config entry.""" host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] + ssl = config_entry.data.get(CONF_USE_SSL, False) session = async_create_clientsession( hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False) ) - client = BraviaClient(host, mac, session=session) + client = BraviaClient(host, mac, session=session, ssl=ssl) coordinator = BraviaTVCoordinator( hass=hass, config_entry=config_entry, diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 1a5aa1fddd6..01ecddad1d4 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -28,6 +28,7 @@ from .const import ( ATTR_MODEL, CONF_NICKNAME, CONF_USE_PSK, + CONF_USE_SSL, DOMAIN, NICKNAME_PREFIX, ) @@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): def create_client(self) -> None: """Create Bravia TV client from config.""" host = self.device_config[CONF_HOST] + ssl = self.device_config[CONF_USE_SSL] session = async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False), ) - self.client = BraviaClient(host=host, session=session) + self.client = BraviaClient(host=host, session=session, ssl=ssl) async def gen_instance_ids(self) -> tuple[str, str]: """Generate client_id and nickname.""" @@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle authorize step.""" - self.create_client() - if user_input is not None: self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK] + self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL] + self.create_client() if user_input[CONF_USE_PSK]: return await self.async_step_psk() return await self.async_step_pin() @@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_USE_PSK, default=False): bool, + vol.Required(CONF_USE_SSL, default=False): bool, } ), ) diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index aadd851fc7f..dc9d452dbbc 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model" CONF_NICKNAME: Final = "nickname" CONF_USE_PSK: Final = "use_psk" +CONF_USE_SSL: Final = "use_ssl" DOMAIN: Final = "braviatv" LEGACY_CLIENT_ID: Final = "HomeAssistant" diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index c3ba71b547f..9af0e920927 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -15,9 +15,10 @@ "step": { "authorize": { "data": { - "use_psk": "Use PSK authentication" + "use_psk": "Use PSK authentication", + "use_ssl": "Use SSL connection" }, - "description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.", + "description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.", "title": "Authorize Sony Bravia TV" }, "confirm": { diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index e59d0b6805b..68dd31af6f7 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -13,6 +13,7 @@ import pytest from homeassistant.components.braviatv.const import ( CONF_NICKNAME, CONF_USE_PSK, + CONF_USE_SSL, DOMAIN, NICKNAME_PREFIX, ) @@ -131,7 +132,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_USE_PSK: False} + result["flow_id"], user_input={CONF_USE_PSK: False, CONF_USE_SSL: False} ) assert result["type"] is FlowResultType.FORM @@ -148,6 +149,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: CONF_HOST: "bravia-host", CONF_PIN: "1234", CONF_USE_PSK: False, + CONF_USE_SSL: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", CONF_CLIENT_ID: uuid, CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", @@ -307,8 +309,17 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_create_entry(hass: HomeAssistant) -> None: - """Test that entry is added correctly with PIN auth.""" +@pytest.mark.parametrize( + ("use_psk", "use_ssl"), + [ + (True, False), + (False, False), + (True, True), + (False, True), + ], +) +async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None: + """Test that entry is added correctly.""" uuid = await instance_id.async_get(hass) with ( @@ -328,14 +339,14 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_USE_PSK: False} + result["flow_id"], user_input={CONF_USE_PSK: use_psk, CONF_USE_SSL: use_ssl} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pin" + assert result["step_id"] == "psk" if use_psk else "pin" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PIN: "1234"} + result["flow_id"], user_input={CONF_PIN: "secret"} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -343,50 +354,18 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", - CONF_PIN: "1234", - CONF_USE_PSK: False, - CONF_MAC: "AA:BB:CC:DD:EE:FF", - CONF_CLIENT_ID: uuid, - CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", - } - - -async def test_create_entry_psk(hass: HomeAssistant) -> None: - """Test that entry is added correctly with PSK auth.""" - with ( - patch("pybravia.BraviaClient.connect"), - patch("pybravia.BraviaClient.set_wol_mode"), - patch( - "pybravia.BraviaClient.get_system_info", - return_value=BRAVIA_SYSTEM_INFO, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "authorize" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_USE_PSK: True} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "psk" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PIN: "mypsk"} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "BRAVIA TV-Model" - assert result["data"] == { - CONF_HOST: "bravia-host", - CONF_PIN: "mypsk", - CONF_USE_PSK: True, + CONF_PIN: "secret", + CONF_USE_PSK: use_psk, + CONF_USE_SSL: use_ssl, CONF_MAC: "AA:BB:CC:DD:EE:FF", + **( + { + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", + } + if not use_psk + else {} + ), } From 05a0f0d23f823491f103296630e0890e7fc0bd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 6 Jan 2026 23:56:26 +0100 Subject: [PATCH 072/163] Bump pyTibber to 0.34.1 (#160380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 61cd5e325e5..9388d413c04 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.34.0"] + "requirements": ["pyTibber==0.34.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4961e9b7098..34413a6fefe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.34.0 +pyTibber==0.34.1 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7d07a5cca9..7ea64b4b892 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.34.0 +pyTibber==0.34.1 # homeassistant.components.dlink pyW215==0.8.0 From e1e7e039a9698ccfb168328a33b20d0b5cda1107 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:59:16 +0100 Subject: [PATCH 073/163] Bump solarlog_cli to 0.7.0 (#160382) --- homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 8276775669f..8d7b8526668 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.6.1"] + "requirements": ["solarlog_cli==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 34413a6fefe..77113a8e961 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2896,7 +2896,7 @@ solaredge-local==0.2.3 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.6.1 +solarlog_cli==0.7.0 # homeassistant.components.solax solax==3.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ea64b4b892..dd89ae4d84c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2420,7 +2420,7 @@ soco==0.30.13 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.6.1 +solarlog_cli==0.7.0 # homeassistant.components.solax solax==3.2.3 From 09c7cc113a37e47e67a0b9e89dd465fe9070e68e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:57:19 +0100 Subject: [PATCH 074/163] Bump uiprotect to 8.0.0 (#160384) Co-authored-by: RaHehl --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 89e74b78472..5ba6b39bcd9 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==7.33.3", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 77113a8e961..71664fa1e65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.33.3 +uiprotect==8.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd89ae4d84c..87eb0f873da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2569,7 +2569,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.33.3 +uiprotect==8.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From e213f49c75bc070f584198d3b17fa86974a1a5ea Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 6 Jan 2026 17:11:54 -0600 Subject: [PATCH 075/163] Bump intents to 2026.1.6 (#160389) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2053bf90255..323ea57b090 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 424869e9bd2..39e6a3188fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251229.1 -home-assistant-intents==2026.1.1 +home-assistant-intents==2026.1.6 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index 84da49c2b3f..a3c9da9a77c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2026.1.1 +home-assistant-intents==2026.1.6 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index 71664fa1e65..0999f14233a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1216,7 +1216,7 @@ holidays==0.84 home-assistant-frontend==20251229.1 # homeassistant.components.conversation -home-assistant-intents==2026.1.1 +home-assistant-intents==2026.1.6 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87eb0f873da..5bc3d91308f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ holidays==0.84 home-assistant-frontend==20251229.1 # homeassistant.components.conversation -home-assistant-intents==2026.1.1 +home-assistant-intents==2026.1.6 # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 From cf12ed8f08a2528bdca1460687c6277be9ddc057 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 31 Dec 2025 07:32:53 -0800 Subject: [PATCH 076/163] Improve roborock test accuracy/robustness (#160021) --- tests/components/roborock/conftest.py | 28 +++++++++++++++++++----- tests/components/roborock/test_vacuum.py | 13 ++++++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 0378ad98cba..06450e0310a 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -148,6 +148,20 @@ class FakeDevice(RoborockDevice): """Close the device.""" +def set_trait_attributes( + trait: AsyncMock, + dataclass_template: RoborockBase, + init_none: bool = False, +) -> None: + """Set attributes on a mock roborock trait.""" + template_copy = deepcopy(dataclass_template) + for attr_name in dir(template_copy): + if attr_name.startswith("_"): + continue + attr_value = getattr(template_copy, attr_name) if not init_none else None + setattr(trait, attr_name, attr_value) + + def make_mock_trait( trait_spec: type[V1TraitMixin] | None = None, dataclass_template: RoborockBase | None = None, @@ -156,12 +170,14 @@ def make_mock_trait( trait = AsyncMock(spec=trait_spec or V1TraitMixin) if dataclass_template is not None: # Copy all attributes and property methods (e.g. computed properties) - template_copy = deepcopy(dataclass_template) - for attr_name in dir(template_copy): - if attr_name.startswith("_"): - continue - setattr(trait, attr_name, getattr(template_copy, attr_name)) - trait.refresh = AsyncMock() + # on the first call to refresh(). The object starts uninitialized. + set_trait_attributes(trait, dataclass_template, init_none=True) + + async def refresh() -> None: + if dataclass_template is not None: + set_trait_attributes(trait, dataclass_template) + + trait.refresh = AsyncMock(side_effect=refresh) return trait diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 56895fab7c0..5c8152bed4c 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -31,7 +31,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import FakeDevice +from .conftest import FakeDevice, set_trait_attributes +from .mock_data import STATUS from tests.common import MockConfigEntry @@ -132,8 +133,14 @@ async def test_resume_cleaning( vacuum_command: Mock, ) -> None: """Test resuming clean on start button when a clean is paused.""" - fake_vacuum.v1_properties.status.in_cleaning = in_cleaning_int - fake_vacuum.v1_properties.status.in_returning = in_returning_int + + async def refresh_properties() -> None: + set_trait_attributes(fake_vacuum.v1_properties.status, STATUS) + fake_vacuum.v1_properties.status.in_cleaning = in_cleaning_int + fake_vacuum.v1_properties.status.in_returning = in_returning_int + + fake_vacuum.v1_properties.status.refresh.side_effect = refresh_properties + await async_setup_component(hass, DOMAIN, {}) vacuum = hass.states.get(ENTITY_ID) assert vacuum From 95596341512f0db7147e9933a6cfd0eec3471d93 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 30 Dec 2025 10:36:32 -0800 Subject: [PATCH 077/163] Update roborock binary sensor tests with snapshots (#159981) --- .../snapshots/test_binary_sensor.ambr | 491 ++ .../roborock/snapshots/test_sensor.ambr | 4392 +++++++++++++---- .../components/roborock/test_binary_sensor.py | 21 +- tests/components/roborock/test_sensor.py | 6 +- 4 files changed, 3915 insertions(+), 995 deletions(-) create mode 100644 tests/components/roborock/snapshots/test_binary_sensor.ambr diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..bcf17faa530 --- /dev/null +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -0,0 +1,491 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.roborock_s7_2_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_charging_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Roborock S7 2 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_cleaning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_cleaning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'in_cleaning', + 'unique_id': 'in_cleaning_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_cleaning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Roborock S7 2 Cleaning', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_cleaning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_attached', + 'unique_id': 'water_box_carriage_status_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Roborock S7 2 Mop attached', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_mop_attached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_box_attached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_water_box_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water box attached', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_box_attached', + 'unique_id': 'water_box_status_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_box_attached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Roborock S7 2 Water box attached', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_water_box_attached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_shortage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_water_shortage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water shortage', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_shortage', + 'unique_id': 'water_shortage_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_shortage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Roborock S7 2 Water shortage', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_water_shortage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_charging_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Roborock S7 MaxV Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_cleaning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_cleaning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'in_cleaning', + 'unique_id': 'in_cleaning_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_cleaning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Roborock S7 MaxV Cleaning', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_cleaning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_attached', + 'unique_id': 'water_box_carriage_status_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Roborock S7 MaxV Mop attached', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_mop_attached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_box_attached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_water_box_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water box attached', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_box_attached', + 'unique_id': 'water_box_status_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_box_attached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Roborock S7 MaxV Water box attached', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_water_box_attached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_shortage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_water_shortage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water shortage', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_shortage', + 'unique_id': 'water_shortage_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_shortage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Roborock S7 MaxV Water shortage', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_water_shortage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index bdf797a079e..12261caac06 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -1,985 +1,3417 @@ # serializer version: 1 -# name: test_sensors - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Main brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_main_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '279.338333333333', +# name: test_sensors[sensor.dyad_pro_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Side brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_side_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '179.338333333333', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Filter time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_filter_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '129.338333333333', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Dock Maintenance brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '235', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Dock Strainer time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_dock_strainer_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '85', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Sensor time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_sensor_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9.33833333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '19.6', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 MaxV Total cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.6616666666667', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 MaxV Total cleaning count', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_count', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 MaxV Status', - 'options': list([ - 'unknown', - 'starting', - 'charger_disconnected', - 'idle', - 'remote_control_active', - 'cleaning', - 'returning_home', - 'manual_mode', - 'charging', - 'charging_problem', - 'paused', - 'spot_cleaning', - 'error', - 'shutting_down', - 'updating', - 'docking', - 'going_to_target', - 'zoned_cleaning', - 'segment_cleaning', - 'emptying_the_bin', - 'washing_the_mop', - 'washing_the_mop_2', - 'going_to_wash_the_mop', - 'in_call', - 'mapping', - 'egg_attack', - 'patrol', - 'attaching_the_mop', - 'detaching_the_mop', - 'charging_complete', - 'device_offline', - 'locked', - 'air_drying_stopping', - 'robot_status_mopping', - 'clean_mop_cleaning', - 'clean_mop_mopping', - 'segment_mopping', - 'segment_clean_mop_cleaning', - 'segment_clean_mop_mopping', - 'zoned_mopping', - 'zoned_clean_mop_cleaning', - 'zoned_clean_mop_mopping', - 'back_to_dock_washing_duster', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charging', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 MaxV Cleaning area', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 MaxV Total cleaning area', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1159.2', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 MaxV Vacuum error', - 'options': list([ - 'none', - 'lidar_blocked', - 'bumper_stuck', - 'wheels_suspended', - 'cliff_sensor_error', - 'main_brush_jammed', - 'side_brush_jammed', - 'wheels_jammed', - 'robot_trapped', - 'no_dustbin', - 'strainer_error', - 'compass_error', - 'low_battery', - 'charging_error', - 'battery_error', - 'wall_sensor_dirty', - 'robot_tilted', - 'side_brush_error', - 'fan_error', - 'dock', - 'optical_flow_sensor_dirt', - 'vertical_bumper_pressed', - 'dock_locator_error', - 'return_to_dock_fail', - 'nogo_zone_detected', - 'visual_sensor', - 'light_touch', - 'vibrarise_jammed', - 'robot_on_carpet', - 'filter_blocked', - 'invisible_wall_detected', - 'cannot_cross_carpet', - 'internal_error', - 'collect_dust_error_3', - 'collect_dust_error_4', - 'mopping_roller_1', - 'mopping_roller_error_2', - 'clear_water_box_hoare', - 'dirty_water_box_hoare', - 'sink_strainer_hoare', - 'clear_water_box_exception', - 'clear_brush_exception', - 'clear_brush_exception_2', - 'filter_screen_exception', - 'mopping_roller_2', - 'up_water_exception', - 'drain_water_exception', - 'temperature_protection', - 'clean_carousel_exception', - 'clean_carousel_water_full', - 'water_carriage_drop', - 'check_clean_carouse', - 'audio_error', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_vacuum_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Roborock S7 MaxV Battery', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 MaxV Last clean begin', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_last_clean_begin', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-01-01T03:22:10+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 MaxV Last clean end', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_last_clean_end', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-01-01T03:43:58+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 MaxV Dock Dock error', - 'options': list([ - 'ok', - 'duct_blockage', - 'water_empty', - 'waste_water_tank_full', - 'maintenance_brush_jammed', - 'dirty_tank_latch_open', - 'no_dustbin', - 'cleaning_tank_full_or_blocked', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_dock_dock_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'ok', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Main brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_main_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '279.338333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Side brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_side_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '179.338333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Filter time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_filter_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '129.338333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Dock Maintenance brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '235', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Dock Strainer time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_dock_strainer_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '85', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Sensor time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_sensor_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9.33833333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '19.6', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Total cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.6616666666667', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Total cleaning count', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_total_cleaning_count', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Status', - 'options': list([ - 'unknown', - 'starting', - 'charger_disconnected', - 'idle', - 'remote_control_active', - 'cleaning', - 'returning_home', - 'manual_mode', - 'charging', - 'charging_problem', - 'paused', - 'spot_cleaning', - 'error', - 'shutting_down', - 'updating', - 'docking', - 'going_to_target', - 'zoned_cleaning', - 'segment_cleaning', - 'emptying_the_bin', - 'washing_the_mop', - 'washing_the_mop_2', - 'going_to_wash_the_mop', - 'in_call', - 'mapping', - 'egg_attack', - 'patrol', - 'attaching_the_mop', - 'detaching_the_mop', - 'charging_complete', - 'device_offline', - 'locked', - 'air_drying_stopping', - 'robot_status_mopping', - 'clean_mop_cleaning', - 'clean_mop_mopping', - 'segment_mopping', - 'segment_clean_mop_cleaning', - 'segment_clean_mop_mopping', - 'zoned_mopping', - 'zoned_clean_mop_cleaning', - 'zoned_clean_mop_mopping', - 'back_to_dock_washing_duster', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charging', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Cleaning area', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Total cleaning area', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_total_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1159.2', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Vacuum error', - 'options': list([ - 'none', - 'lidar_blocked', - 'bumper_stuck', - 'wheels_suspended', - 'cliff_sensor_error', - 'main_brush_jammed', - 'side_brush_jammed', - 'wheels_jammed', - 'robot_trapped', - 'no_dustbin', - 'strainer_error', - 'compass_error', - 'low_battery', - 'charging_error', - 'battery_error', - 'wall_sensor_dirty', - 'robot_tilted', - 'side_brush_error', - 'fan_error', - 'dock', - 'optical_flow_sensor_dirt', - 'vertical_bumper_pressed', - 'dock_locator_error', - 'return_to_dock_fail', - 'nogo_zone_detected', - 'visual_sensor', - 'light_touch', - 'vibrarise_jammed', - 'robot_on_carpet', - 'filter_blocked', - 'invisible_wall_detected', - 'cannot_cross_carpet', - 'internal_error', - 'collect_dust_error_3', - 'collect_dust_error_4', - 'mopping_roller_1', - 'mopping_roller_error_2', - 'clear_water_box_hoare', - 'dirty_water_box_hoare', - 'sink_strainer_hoare', - 'clear_water_box_exception', - 'clear_brush_exception', - 'clear_brush_exception_2', - 'filter_screen_exception', - 'mopping_roller_2', - 'up_water_exception', - 'drain_water_exception', - 'temperature_protection', - 'clean_carousel_exception', - 'clean_carousel_water_full', - 'water_carriage_drop', - 'check_clean_carouse', - 'audio_error', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_vacuum_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Roborock S7 2 Battery', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 2 Last clean begin', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_last_clean_begin', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-01-01T03:22:10+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 2 Last clean end', - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_last_clean_end', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-01-01T03:43:58+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Dock Dock error', - 'options': list([ - 'ok', - 'duct_blockage', - 'water_empty', - 'waste_water_tank_full', - 'maintenance_brush_jammed', - 'dirty_tank_latch_open', - 'no_dustbin', - 'cleaning_tank_full_or_blocked', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_dock_dock_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'ok', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 MaxV Current room', - 'options': list([ - 'Example room 1', - 'Example room 2', - 'Example room 3', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_current_room', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Example room 2', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Current room', - 'options': list([ - 'Example room 1', - 'Example room 2', - 'Example room 3', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_current_room', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Example room 2', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Dyad Pro Status', - 'options': list([ - 'unknown', - 'fetching', - 'fetch_failed', - 'updating', - 'washing', - 'ready', - 'charging', - 'mop_washing', - 'self_clean_cleaning', - 'self_clean_deep_cleaning', - 'self_clean_rinsing', - 'self_clean_dehydrating', - 'drying', - 'ventilating', - 'reserving', - 'mop_washing_paused', - 'dusting_mode', - ]), - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'drying', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Dyad Pro Battery', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Dyad Pro Filter time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_filter_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0308333333333333', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Dyad Pro Roller left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_roller_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0616666666666667', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Dyad Pro Error', - 'options': list([ - 'none', - 'dirty_tank_full', - 'water_level_sensor_stuck', - 'clean_tank_empty', - 'clean_head_entangled', - 'clean_head_too_hot', - 'fan_protection_e5', - 'cleaning_head_blocked', - 'temperature_protection', - 'fan_protection_e4', - 'fan_protection_e9', - 'battery_temperature_protection_e0', - 'battery_temperature_protection', - 'battery_temperature_protection_2', - 'power_adapter_error', - 'dirty_charging_contacts', - 'low_battery', - 'battery_under_10', - ]), - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Dyad Pro Total cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dyad_pro_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.55', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One State', - 'options': list([ - 'standby', - 'weighing', - 'soaking', - 'washing', - 'rinsing', - 'spinning', - 'drying', - 'cooling', - 'under_delay_start', - 'done', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'drying', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Zeo One Countdown', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zeo_one_countdown', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Zeo One Washing left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zeo_one_washing_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '253', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One Error', - 'options': list([ - 'none', - 'refill_error', - 'drain_error', - 'door_lock_error', - 'water_level_error', - 'inverter_error', - 'heating_error', - 'temperature_error', - 'communication_error', - 'drying_error', - 'drying_error_e_12', - 'drying_error_e_13', - 'drying_error_e_14', - 'drying_error_e_15', - 'drying_error_e_16', - 'drying_error_water_flow', - 'drying_error_restart', - 'spin_error', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Roborock Q7 Status', - 'options': list([ - 'sleeping', - 'waiting_for_orders', - 'paused', - 'docking', - 'charging', - 'sweep_moping', - 'sweep_moping_2', - 'moping', - 'updating', - 'mop_cleaning', - 'mop_airdrying', - ]), - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'sweep_moping', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Main brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_main_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '216.666666666667', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Side brush time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_side_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '150.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Filter time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_filter_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '125.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Sensor time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_sensor_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.6666666666667', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Mop life time left', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_mop_life_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '160.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Total cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50.0', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_dyad_duid', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.dyad_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dyad Pro Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.dyad_pro_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'dirty_tank_full', + 'water_level_sensor_stuck', + 'clean_tank_empty', + 'clean_head_entangled', + 'clean_head_too_hot', + 'fan_protection_e5', + 'cleaning_head_blocked', + 'temperature_protection', + 'fan_protection_e4', + 'fan_protection_e9', + 'battery_temperature_protection_e0', + 'battery_temperature_protection', + 'battery_temperature_protection_2', + 'power_adapter_error', + 'dirty_charging_contacts', + 'low_battery', + 'battery_under_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'a01_error', + 'unique_id': 'error_dyad_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.dyad_pro_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dyad Pro Error', + 'options': list([ + 'none', + 'dirty_tank_full', + 'water_level_sensor_stuck', + 'clean_tank_empty', + 'clean_head_entangled', + 'clean_head_too_hot', + 'fan_protection_e5', + 'cleaning_head_blocked', + 'temperature_protection', + 'fan_protection_e4', + 'fan_protection_e9', + 'battery_temperature_protection_e0', + 'battery_temperature_protection', + 'battery_temperature_protection_2', + 'power_adapter_error', + 'dirty_charging_contacts', + 'low_battery', + 'battery_under_10', + ]), + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensors[sensor.dyad_pro_filter_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_filter_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_time_left', + 'unique_id': 'filter_time_left_dyad_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.dyad_pro_filter_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dyad Pro Filter time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_filter_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0308333333333333', + }) +# --- +# name: test_sensors[sensor.dyad_pro_roller_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_roller_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Roller left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brush_remaining', + 'unique_id': 'brush_remaining_dyad_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.dyad_pro_roller_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dyad Pro Roller left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_roller_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0616666666666667', + }) +# --- +# name: test_sensors[sensor.dyad_pro_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unknown', + 'fetching', + 'fetch_failed', + 'updating', + 'washing', + 'ready', + 'charging', + 'mop_washing', + 'self_clean_cleaning', + 'self_clean_deep_cleaning', + 'self_clean_rinsing', + 'self_clean_dehydrating', + 'drying', + 'ventilating', + 'reserving', + 'mop_washing_paused', + 'dusting_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'a01_status', + 'unique_id': 'status_dyad_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.dyad_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dyad Pro Status', + 'options': list([ + 'unknown', + 'fetching', + 'fetch_failed', + 'updating', + 'washing', + 'ready', + 'charging', + 'mop_washing', + 'self_clean_cleaning', + 'self_clean_deep_cleaning', + 'self_clean_rinsing', + 'self_clean_dehydrating', + 'drying', + 'ventilating', + 'reserving', + 'mop_washing_paused', + 'dusting_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'drying', + }) +# --- +# name: test_sensors[sensor.dyad_pro_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dyad_pro_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_dyad_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.dyad_pro_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dyad Pro Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dyad_pro_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.55', + }) +# --- +# name: test_sensors[sensor.roborock_q7_filter_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_filter_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_time_left', + 'unique_id': 'filter_time_left_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_filter_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Filter time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_filter_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '125.0', + }) +# --- +# name: test_sensors[sensor.roborock_q7_main_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_main_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_time_left', + 'unique_id': 'main_brush_time_left_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_main_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Main brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_main_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.666666666667', + }) +# --- +# name: test_sensors[sensor.roborock_q7_mop_life_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_mop_life_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop life time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_life_time_left', + 'unique_id': 'mop_life_time_left_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_mop_life_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Mop life time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_mop_life_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_sensors[sensor.roborock_q7_sensor_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_sensor_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_time_left', + 'unique_id': 'sensor_time_left_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_sensor_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Sensor time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_sensor_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.6666666666667', + }) +# --- +# name: test_sensors[sensor.roborock_q7_side_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_side_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_time_left', + 'unique_id': 'side_brush_time_left_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_side_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Side brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_side_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.0', + }) +# --- +# name: test_sensors[sensor.roborock_q7_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'sleeping', + 'waiting_for_orders', + 'paused', + 'docking', + 'charging', + 'sweep_moping', + 'sweep_moping_2', + 'moping', + 'updating', + 'mop_cleaning', + 'mop_airdrying', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'q7_status', + 'unique_id': 'q7_status_q7_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_q7_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock Q7 Status', + 'options': list([ + 'sleeping', + 'waiting_for_orders', + 'paused', + 'docking', + 'charging', + 'sweep_moping', + 'sweep_moping_2', + 'moping', + 'updating', + 'mop_cleaning', + 'mop_airdrying', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sweep_moping', + }) +# --- +# name: test_sensors[sensor.roborock_q7_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q7_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_q7_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q7_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q7 Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q7_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_device_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Roborock S7 2 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'cleaning_area_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'cleaning_time_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_current_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Example room 1', + 'Example room 2', + 'Example room 3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_current_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current room', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_room', + 'unique_id': 'current_room_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_current_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 2 Current room', + 'options': list([ + 'Example room 1', + 'Example room 2', + 'Example room 3', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_current_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Example room 2', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_dock_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'duct_blockage', + 'water_empty', + 'waste_water_tank_full', + 'maintenance_brush_jammed', + 'dirty_tank_latch_open', + 'no_dustbin', + 'cleaning_tank_full_or_blocked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_dock_dock_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dock error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_error', + 'unique_id': 'dock_error_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_dock_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 2 Dock Dock error', + 'options': list([ + 'ok', + 'duct_blockage', + 'water_empty', + 'waste_water_tank_full', + 'maintenance_brush_jammed', + 'dirty_tank_latch_open', + 'no_dustbin', + 'cleaning_tank_full_or_blocked', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_dock_dock_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_maintenance_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maintenance brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_brush_time_left', + 'unique_id': 'cleaning_brush_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_maintenance_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Dock Maintenance brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_strainer_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_dock_strainer_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Strainer time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'strainer_time_left', + 'unique_id': 'strainer_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_strainer_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Dock Strainer time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_dock_strainer_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_filter_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_filter_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_time_left', + 'unique_id': 'filter_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_filter_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Filter time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_filter_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_last_clean_begin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_last_clean_begin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean begin', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_start', + 'unique_id': 'last_clean_start_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_last_clean_begin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Roborock S7 2 Last clean begin', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_last_clean_begin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-01T03:22:10+00:00', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_last_clean_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_last_clean_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean end', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_end', + 'unique_id': 'last_clean_end_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_last_clean_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Roborock S7 2 Last clean end', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_last_clean_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-01T03:43:58+00:00', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_main_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_main_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_time_left', + 'unique_id': 'main_brush_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_main_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Main brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_main_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '279.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_sensor_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_sensor_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_time_left', + 'unique_id': 'sensor_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_sensor_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Sensor time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_sensor_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.33833333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_side_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_side_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_time_left', + 'unique_id': 'side_brush_time_left_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_side_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Side brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_side_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '179.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unknown', + 'starting', + 'charger_disconnected', + 'idle', + 'remote_control_active', + 'cleaning', + 'returning_home', + 'manual_mode', + 'charging', + 'charging_problem', + 'paused', + 'spot_cleaning', + 'error', + 'shutting_down', + 'updating', + 'docking', + 'going_to_target', + 'zoned_cleaning', + 'segment_cleaning', + 'emptying_the_bin', + 'washing_the_mop', + 'washing_the_mop_2', + 'going_to_wash_the_mop', + 'in_call', + 'mapping', + 'egg_attack', + 'patrol', + 'attaching_the_mop', + 'detaching_the_mop', + 'charging_complete', + 'device_offline', + 'locked', + 'air_drying_stopping', + 'robot_status_mopping', + 'clean_mop_cleaning', + 'clean_mop_mopping', + 'segment_mopping', + 'segment_clean_mop_cleaning', + 'segment_clean_mop_mopping', + 'zoned_mopping', + 'zoned_clean_mop_cleaning', + 'zoned_clean_mop_mopping', + 'back_to_dock_washing_duster', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'status_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 2 Status', + 'options': list([ + 'unknown', + 'starting', + 'charger_disconnected', + 'idle', + 'remote_control_active', + 'cleaning', + 'returning_home', + 'manual_mode', + 'charging', + 'charging_problem', + 'paused', + 'spot_cleaning', + 'error', + 'shutting_down', + 'updating', + 'docking', + 'going_to_target', + 'zoned_cleaning', + 'segment_cleaning', + 'emptying_the_bin', + 'washing_the_mop', + 'washing_the_mop_2', + 'going_to_wash_the_mop', + 'in_call', + 'mapping', + 'egg_attack', + 'patrol', + 'attaching_the_mop', + 'detaching_the_mop', + 'charging_complete', + 'device_offline', + 'locked', + 'air_drying_stopping', + 'robot_status_mopping', + 'clean_mop_cleaning', + 'clean_mop_mopping', + 'segment_mopping', + 'segment_clean_mop_cleaning', + 'segment_clean_mop_mopping', + 'zoned_mopping', + 'zoned_clean_mop_cleaning', + 'zoned_clean_mop_mopping', + 'back_to_dock_washing_duster', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'total_cleaning_area_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Total cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1159.2', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_count-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': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_count', + 'unique_id': 'total_cleaning_count_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Total cleaning count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.6616666666667', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_vacuum_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'lidar_blocked', + 'bumper_stuck', + 'wheels_suspended', + 'cliff_sensor_error', + 'main_brush_jammed', + 'side_brush_jammed', + 'wheels_jammed', + 'robot_trapped', + 'no_dustbin', + 'strainer_error', + 'compass_error', + 'low_battery', + 'charging_error', + 'battery_error', + 'wall_sensor_dirty', + 'robot_tilted', + 'side_brush_error', + 'fan_error', + 'dock', + 'optical_flow_sensor_dirt', + 'vertical_bumper_pressed', + 'dock_locator_error', + 'return_to_dock_fail', + 'nogo_zone_detected', + 'visual_sensor', + 'light_touch', + 'vibrarise_jammed', + 'robot_on_carpet', + 'filter_blocked', + 'invisible_wall_detected', + 'cannot_cross_carpet', + 'internal_error', + 'collect_dust_error_3', + 'collect_dust_error_4', + 'mopping_roller_1', + 'mopping_roller_error_2', + 'clear_water_box_hoare', + 'dirty_water_box_hoare', + 'sink_strainer_hoare', + 'clear_water_box_exception', + 'clear_brush_exception', + 'clear_brush_exception_2', + 'filter_screen_exception', + 'mopping_roller_2', + 'up_water_exception', + 'drain_water_exception', + 'temperature_protection', + 'clean_carousel_exception', + 'clean_carousel_water_full', + 'water_carriage_drop', + 'check_clean_carouse', + 'audio_error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_vacuum_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vacuum error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': 'vacuum_error_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_vacuum_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 2 Vacuum error', + 'options': list([ + 'none', + 'lidar_blocked', + 'bumper_stuck', + 'wheels_suspended', + 'cliff_sensor_error', + 'main_brush_jammed', + 'side_brush_jammed', + 'wheels_jammed', + 'robot_trapped', + 'no_dustbin', + 'strainer_error', + 'compass_error', + 'low_battery', + 'charging_error', + 'battery_error', + 'wall_sensor_dirty', + 'robot_tilted', + 'side_brush_error', + 'fan_error', + 'dock', + 'optical_flow_sensor_dirt', + 'vertical_bumper_pressed', + 'dock_locator_error', + 'return_to_dock_fail', + 'nogo_zone_detected', + 'visual_sensor', + 'light_touch', + 'vibrarise_jammed', + 'robot_on_carpet', + 'filter_blocked', + 'invisible_wall_detected', + 'cannot_cross_carpet', + 'internal_error', + 'collect_dust_error_3', + 'collect_dust_error_4', + 'mopping_roller_1', + 'mopping_roller_error_2', + 'clear_water_box_hoare', + 'dirty_water_box_hoare', + 'sink_strainer_hoare', + 'clear_water_box_exception', + 'clear_brush_exception', + 'clear_brush_exception_2', + 'filter_screen_exception', + 'mopping_roller_2', + 'up_water_exception', + 'drain_water_exception', + 'temperature_protection', + 'clean_carousel_exception', + 'clean_carousel_water_full', + 'water_carriage_drop', + 'check_clean_carouse', + 'audio_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_vacuum_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_abc123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Roborock S7 MaxV Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'cleaning_area_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'cleaning_time_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_current_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Example room 1', + 'Example room 2', + 'Example room 3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_current_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current room', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_room', + 'unique_id': 'current_room_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_current_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 MaxV Current room', + 'options': list([ + 'Example room 1', + 'Example room 2', + 'Example room 3', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_current_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Example room 2', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_dock_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'duct_blockage', + 'water_empty', + 'waste_water_tank_full', + 'maintenance_brush_jammed', + 'dirty_tank_latch_open', + 'no_dustbin', + 'cleaning_tank_full_or_blocked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_dock_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dock error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_error', + 'unique_id': 'dock_error_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_dock_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 MaxV Dock Dock error', + 'options': list([ + 'ok', + 'duct_blockage', + 'water_empty', + 'waste_water_tank_full', + 'maintenance_brush_jammed', + 'dirty_tank_latch_open', + 'no_dustbin', + 'cleaning_tank_full_or_blocked', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_dock_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_maintenance_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maintenance brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_brush_time_left', + 'unique_id': 'cleaning_brush_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_maintenance_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Dock Maintenance brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_strainer_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_strainer_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Strainer time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'strainer_time_left', + 'unique_id': 'strainer_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_strainer_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Dock Strainer time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_strainer_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_filter_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_filter_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_time_left', + 'unique_id': 'filter_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_filter_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Filter time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_filter_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_last_clean_begin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_last_clean_begin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean begin', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_start', + 'unique_id': 'last_clean_start_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_last_clean_begin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Roborock S7 MaxV Last clean begin', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_last_clean_begin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-01T03:22:10+00:00', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_last_clean_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_last_clean_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean end', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_end', + 'unique_id': 'last_clean_end_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_last_clean_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Roborock S7 MaxV Last clean end', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_last_clean_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-01T03:43:58+00:00', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_main_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_main_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_time_left', + 'unique_id': 'main_brush_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_main_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Main brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_main_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '279.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_sensor_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_sensor_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_time_left', + 'unique_id': 'sensor_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_sensor_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Sensor time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_sensor_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.33833333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_side_brush_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_side_brush_time_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush time left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_time_left', + 'unique_id': 'side_brush_time_left_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_side_brush_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Side brush time left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_side_brush_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '179.338333333333', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unknown', + 'starting', + 'charger_disconnected', + 'idle', + 'remote_control_active', + 'cleaning', + 'returning_home', + 'manual_mode', + 'charging', + 'charging_problem', + 'paused', + 'spot_cleaning', + 'error', + 'shutting_down', + 'updating', + 'docking', + 'going_to_target', + 'zoned_cleaning', + 'segment_cleaning', + 'emptying_the_bin', + 'washing_the_mop', + 'washing_the_mop_2', + 'going_to_wash_the_mop', + 'in_call', + 'mapping', + 'egg_attack', + 'patrol', + 'attaching_the_mop', + 'detaching_the_mop', + 'charging_complete', + 'device_offline', + 'locked', + 'air_drying_stopping', + 'robot_status_mopping', + 'clean_mop_cleaning', + 'clean_mop_mopping', + 'segment_mopping', + 'segment_clean_mop_cleaning', + 'segment_clean_mop_mopping', + 'zoned_mopping', + 'zoned_clean_mop_cleaning', + 'zoned_clean_mop_mopping', + 'back_to_dock_washing_duster', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'status_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 MaxV Status', + 'options': list([ + 'unknown', + 'starting', + 'charger_disconnected', + 'idle', + 'remote_control_active', + 'cleaning', + 'returning_home', + 'manual_mode', + 'charging', + 'charging_problem', + 'paused', + 'spot_cleaning', + 'error', + 'shutting_down', + 'updating', + 'docking', + 'going_to_target', + 'zoned_cleaning', + 'segment_cleaning', + 'emptying_the_bin', + 'washing_the_mop', + 'washing_the_mop_2', + 'going_to_wash_the_mop', + 'in_call', + 'mapping', + 'egg_attack', + 'patrol', + 'attaching_the_mop', + 'detaching_the_mop', + 'charging_complete', + 'device_offline', + 'locked', + 'air_drying_stopping', + 'robot_status_mopping', + 'clean_mop_cleaning', + 'clean_mop_mopping', + 'segment_mopping', + 'segment_clean_mop_cleaning', + 'segment_clean_mop_mopping', + 'zoned_mopping', + 'zoned_clean_mop_cleaning', + 'zoned_clean_mop_mopping', + 'back_to_dock_washing_duster', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'total_cleaning_area_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Total cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1159.2', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_count-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': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_count', + 'unique_id': 'total_cleaning_count_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Total cleaning count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.6616666666667', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_vacuum_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'lidar_blocked', + 'bumper_stuck', + 'wheels_suspended', + 'cliff_sensor_error', + 'main_brush_jammed', + 'side_brush_jammed', + 'wheels_jammed', + 'robot_trapped', + 'no_dustbin', + 'strainer_error', + 'compass_error', + 'low_battery', + 'charging_error', + 'battery_error', + 'wall_sensor_dirty', + 'robot_tilted', + 'side_brush_error', + 'fan_error', + 'dock', + 'optical_flow_sensor_dirt', + 'vertical_bumper_pressed', + 'dock_locator_error', + 'return_to_dock_fail', + 'nogo_zone_detected', + 'visual_sensor', + 'light_touch', + 'vibrarise_jammed', + 'robot_on_carpet', + 'filter_blocked', + 'invisible_wall_detected', + 'cannot_cross_carpet', + 'internal_error', + 'collect_dust_error_3', + 'collect_dust_error_4', + 'mopping_roller_1', + 'mopping_roller_error_2', + 'clear_water_box_hoare', + 'dirty_water_box_hoare', + 'sink_strainer_hoare', + 'clear_water_box_exception', + 'clear_brush_exception', + 'clear_brush_exception_2', + 'filter_screen_exception', + 'mopping_roller_2', + 'up_water_exception', + 'drain_water_exception', + 'temperature_protection', + 'clean_carousel_exception', + 'clean_carousel_water_full', + 'water_carriage_drop', + 'check_clean_carouse', + 'audio_error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_vacuum_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vacuum error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': 'vacuum_error_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_vacuum_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock S7 MaxV Vacuum error', + 'options': list([ + 'none', + 'lidar_blocked', + 'bumper_stuck', + 'wheels_suspended', + 'cliff_sensor_error', + 'main_brush_jammed', + 'side_brush_jammed', + 'wheels_jammed', + 'robot_trapped', + 'no_dustbin', + 'strainer_error', + 'compass_error', + 'low_battery', + 'charging_error', + 'battery_error', + 'wall_sensor_dirty', + 'robot_tilted', + 'side_brush_error', + 'fan_error', + 'dock', + 'optical_flow_sensor_dirt', + 'vertical_bumper_pressed', + 'dock_locator_error', + 'return_to_dock_fail', + 'nogo_zone_detected', + 'visual_sensor', + 'light_touch', + 'vibrarise_jammed', + 'robot_on_carpet', + 'filter_blocked', + 'invisible_wall_detected', + 'cannot_cross_carpet', + 'internal_error', + 'collect_dust_error_3', + 'collect_dust_error_4', + 'mopping_roller_1', + 'mopping_roller_error_2', + 'clear_water_box_hoare', + 'dirty_water_box_hoare', + 'sink_strainer_hoare', + 'clear_water_box_exception', + 'clear_brush_exception', + 'clear_brush_exception_2', + 'filter_screen_exception', + 'mopping_roller_2', + 'up_water_exception', + 'drain_water_exception', + 'temperature_protection', + 'clean_carousel_exception', + 'clean_carousel_water_full', + 'water_carriage_drop', + 'check_clean_carouse', + 'audio_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_vacuum_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensors[sensor.zeo_one_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'countdown_zeo_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.zeo_one_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Zeo One Countdown', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zeo_one_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.zeo_one_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'refill_error', + 'drain_error', + 'door_lock_error', + 'water_level_error', + 'inverter_error', + 'heating_error', + 'temperature_error', + 'communication_error', + 'drying_error', + 'drying_error_e_12', + 'drying_error_e_13', + 'drying_error_e_14', + 'drying_error_e_15', + 'drying_error_e_16', + 'drying_error_water_flow', + 'drying_error_restart', + 'spin_error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zeo_error', + 'unique_id': 'error_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One Error', + 'options': list([ + 'none', + 'refill_error', + 'drain_error', + 'door_lock_error', + 'water_level_error', + 'inverter_error', + 'heating_error', + 'temperature_error', + 'communication_error', + 'drying_error', + 'drying_error_e_12', + 'drying_error_e_13', + 'drying_error_e_14', + 'drying_error_e_15', + 'drying_error_e_16', + 'drying_error_water_flow', + 'drying_error_restart', + 'spin_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensors[sensor.zeo_one_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'weighing', + 'soaking', + 'washing', + 'rinsing', + 'spinning', + 'drying', + 'cooling', + 'under_delay_start', + 'done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zeo_state', + 'unique_id': 'state_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One State', + 'options': list([ + 'standby', + 'weighing', + 'soaking', + 'washing', + 'rinsing', + 'spinning', + 'drying', + 'cooling', + 'under_delay_start', + 'done', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'drying', + }) +# --- +# name: test_sensors[sensor.zeo_one_washing_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_washing_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washing left', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'washing_left', + 'unique_id': 'washing_left_zeo_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.zeo_one_washing_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Zeo One Washing left', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zeo_one_washing_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '253', + }) # --- diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 6a234d735e5..d1c2ba039b5 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,11 +1,13 @@ """Test Roborock Binary Sensor.""" import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture @@ -15,17 +17,10 @@ def platforms() -> list[Platform]: async def test_binary_sensors( - hass: HomeAssistant, setup_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 10 - assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" - assert ( - hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state - == "on" - ) - assert ( - hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" - ) - assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" - assert hass.states.get("binary_sensor.roborock_s7_maxv_charging").state == "on" + await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 81fd1eb90e5..7b14fec6204 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -5,8 +5,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture @@ -17,8 +18,9 @@ def platforms() -> list[Platform]: async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, setup_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test sensors and check test values are correctly set.""" - assert snapshot == hass.states.async_all("sensor") + await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) From 786257e0513a2a5f0ae6603d95c356a7cd01e677 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 6 Jan 2026 23:27:09 -0500 Subject: [PATCH 078/163] Remove q7 total cleaning time for Roborock (#160399) --- homeassistant/components/roborock/sensor.py | 9 --- .../roborock/snapshots/test_sensor.ambr | 56 ------------------- 2 files changed, 65 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 24f2d340e38..0b05996cf8c 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -391,15 +391,6 @@ Q7_B01_SENSOR_DESCRIPTIONS = [ translation_key="mop_life_time_left", entity_category=EntityCategory.DIAGNOSTIC, ), - RoborockSensorDescriptionB01( - key="total_cleaning_time", - value_fn=lambda data: data.real_clean_time, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - suggested_unit_of_measurement=UnitOfTime.HOURS, - translation_key="total_cleaning_time", - entity_category=EntityCategory.DIAGNOSTIC, - ), ] diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 12261caac06..c0e38f83f1f 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -751,62 +751,6 @@ 'state': 'sweep_moping', }) # --- -# name: test_sensors[sensor.roborock_q7_total_cleaning_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.roborock_q7_total_cleaning_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total cleaning time', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'total_cleaning_time', - 'unique_id': 'total_cleaning_time_q7_duid', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.roborock_q7_total_cleaning_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Roborock Q7 Total cleaning time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.roborock_q7_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50.0', - }) -# --- # name: test_sensors[sensor.roborock_s7_2_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From b3765204b1ffbe826f2b46a0b339e6fcb97845ff Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 7 Jan 2026 05:23:40 -0500 Subject: [PATCH 079/163] Bump python-roborock to 4.2.1 (#160398) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index b025cb1e6cb..55490df576b 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==4.2.0", + "python-roborock==4.2.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 0999f14233a..85d58d2e594 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==4.2.0 +python-roborock==4.2.1 # homeassistant.components.smarttub python-smarttub==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bc3d91308f..95d7fbbfaea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==4.2.0 +python-roborock==4.2.1 # homeassistant.components.smarttub python-smarttub==0.0.46 From c201938b8b5ea05a663be10ee42770d9ba6472b7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 7 Jan 2026 14:21:49 +0100 Subject: [PATCH 080/163] Constraint aiomqtt>=2.5.0 to fix blocking call (#160410) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39e6a3188fd..b857bdb57e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -226,3 +226,6 @@ gql<4.0.0 # Pin pytest-rerunfailures to prevent accidental breaks pytest-rerunfailures==16.0.1 + +# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475 +aiomqtt>=2.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0f542aeb1bb..e3d20f23d17 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -217,6 +217,9 @@ gql<4.0.0 # Pin pytest-rerunfailures to prevent accidental breaks pytest-rerunfailures==16.0.1 + +# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475 +aiomqtt>=2.5.0 """ GENERATED_MESSAGE = ( From fd92377cf257491c3064ab03dc4aac29ee86a867 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jan 2026 14:53:13 +0100 Subject: [PATCH 081/163] Bump version to 2026.1.0b5 --- 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 2714c3634a1..b8811456c16 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __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 d6eb1df2371..79be6e1e1c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b4" +version = "2026.1.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From da19cc06e30f29ad759240874091f8e10db1b78d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 7 Jan 2026 17:44:03 +0100 Subject: [PATCH 082/163] Fix hvac_mode validation in climate.hvac_mode_changed trigger (#160364) --- homeassistant/components/climate/trigger.py | 2 +- tests/components/climate/test_trigger.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index fa02031b2ea..10f40cad661 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -33,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend { vol.Required(CONF_OPTIONS): { vol.Required(CONF_HVAC_MODE): vol.All( - cv.ensure_list, vol.Length(min=1), [HVACMode] + cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)] ), }, } diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 6af1c638d30..f46624826fb 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -105,12 +105,12 @@ async def test_climate_triggers_gated_by_labs_flag( # Valid configurations ( "climate.hvac_mode_changed", - {CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]}, + {CONF_HVAC_MODE: ["heat", "cool"]}, does_not_raise(), ), ( "climate.hvac_mode_changed", - {CONF_HVAC_MODE: HVACMode.HEAT}, + {CONF_HVAC_MODE: "heat"}, does_not_raise(), ), # Invalid configurations @@ -305,7 +305,7 @@ def parametrize_xxx_crossed_threshold_trigger_states( [ *parametrize_climate_trigger_states( trigger="climate.hvac_mode_changed", - trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]}, + trigger_options={CONF_HVAC_MODE: ["heat", "cool"]}, target_states=[HVACMode.HEAT, HVACMode.COOL], other_states=other_states([HVACMode.HEAT, HVACMode.COOL]), ), @@ -465,7 +465,7 @@ async def test_climate_state_attribute_trigger_behavior_any( [ *parametrize_climate_trigger_states( trigger="climate.hvac_mode_changed", - trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]}, + trigger_options={CONF_HVAC_MODE: ["heat", "cool"]}, target_states=[HVACMode.HEAT, HVACMode.COOL], other_states=other_states([HVACMode.HEAT, HVACMode.COOL]), ), @@ -615,7 +615,7 @@ async def test_climate_state_attribute_trigger_behavior_first( [ *parametrize_climate_trigger_states( trigger="climate.hvac_mode_changed", - trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]}, + trigger_options={CONF_HVAC_MODE: ["heat", "cool"]}, target_states=[HVACMode.HEAT, HVACMode.COOL], other_states=other_states([HVACMode.HEAT, HVACMode.COOL]), ), From 9f0eb6f077a54e99102b7ab26a4e252ac132ca52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 7 Jan 2026 14:15:44 +0000 Subject: [PATCH 083/163] Support target triggers in automation relation extraction (#160369) --- .../components/automation/__init__.py | 50 ++++- tests/components/automation/test_init.py | 196 ++++++++++++++++++ 2 files changed, 240 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 03cfe6065b5..91f16bc42e4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,7 +7,7 @@ import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass import logging -from typing import Any, Protocol, cast +from typing import Any, Literal, Protocol, cast from propcache.api import cached_property import voluptuous as vol @@ -16,7 +16,10 @@ from homeassistant.components import labs, websocket_api from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.components.labs import async_listen as async_labs_listen from homeassistant.const import ( + ATTR_AREA_ID, ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, ATTR_MODE, ATTR_NAME, CONF_ACTIONS, @@ -30,6 +33,7 @@ from homeassistant.const import ( CONF_OPTIONS, CONF_PATH, CONF_PLATFORM, + CONF_TARGET, CONF_TRIGGERS, CONF_VARIABLES, CONF_ZONE, @@ -588,20 +592,32 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): """Return True if entity is on.""" return self._async_detach_triggers is not None or self._is_enabled - @property + @cached_property def referenced_labels(self) -> set[str]: """Return a set of referenced labels.""" - return self.action_script.referenced_labels + referenced = self.action_script.referenced_labels - @property + for conf in self._trigger_config: + referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID)) + return referenced + + @cached_property def referenced_floors(self) -> set[str]: """Return a set of referenced floors.""" - return self.action_script.referenced_floors + referenced = self.action_script.referenced_floors + + for conf in self._trigger_config: + referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID)) + return referenced @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" - return self.action_script.referenced_areas + referenced = self.action_script.referenced_areas + + for conf in self._trigger_config: + referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID)) + return referenced @property def referenced_blueprint(self) -> str | None: @@ -1209,6 +1225,9 @@ def _trigger_extract_devices(trigger_conf: dict) -> list[str]: if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf: return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return] + if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID): + return target_devices + return [] @@ -1239,9 +1258,28 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]: ): return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]] + if target_entities := _get_targets_from_trigger_config( + trigger_conf, CONF_ENTITY_ID + ): + return target_entities + return [] +@callback +def _get_targets_from_trigger_config( + config: dict, + target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"], +) -> list[str]: + """Extract targets from a target config.""" + if not (target_conf := config.get(CONF_TARGET)): + return [] + if not (targets := target_conf.get(target)): + return [] + + return [targets] if isinstance(targets, str) else targets + + @websocket_api.websocket_command({"type": "automation/config", "entity_id": str}) def websocket_config( hass: HomeAssistant, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 0b5f8e109ce..6bacee92f5e 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2232,6 +2232,202 @@ async def test_extraction_functions( assert automation.blueprint_in_automation(hass, "automation.test3") is None +async def test_extraction_functions_with_targets( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test extraction functions with targets in triggers. + + This test verifies that targets specified in trigger configurations + (using new-style triggers that support target) are properly extracted for + entity, device, area, floor, and label references. + """ + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.mock_state(hass, ConfigEntryState.LOADED) + config_entry.add_to_hass(hass) + + trigger_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + + await async_setup_component(hass, "homeassistant", {}) + await async_setup_component( + hass, "scene", {"scene": {"name": "test", "entities": {}}} + ) + await hass.async_block_till_done() + + # Enable the new_triggers_conditions feature flag to allow new-style triggers + assert await async_setup_component(hass, "labs", {}) + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + { + "type": "labs/update", + "domain": "automation", + "preview_feature": "new_triggers_conditions", + "enabled": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "alias": "test1", + "triggers": [ + # Single entity_id in target + { + "trigger": "scene.activated", + "target": {"entity_id": "scene.target_entity"}, + }, + # Multiple entity_ids in target + { + "trigger": "scene.activated", + "target": { + "entity_id": [ + "scene.target_entity_list1", + "scene.target_entity_list2", + ] + }, + }, + # Single device_id in target + { + "trigger": "scene.activated", + "target": {"device_id": trigger_device.id}, + }, + # Multiple device_ids in target + { + "trigger": "scene.activated", + "target": { + "device_id": [ + "target-device-1", + "target-device-2", + ] + }, + }, + # Single area_id in target + { + "trigger": "scene.activated", + "target": {"area_id": "area-target-single"}, + }, + # Multiple area_ids in target + { + "trigger": "scene.activated", + "target": {"area_id": ["area-target-1", "area-target-2"]}, + }, + # Single floor_id in target + { + "trigger": "scene.activated", + "target": {"floor_id": "floor-target-single"}, + }, + # Multiple floor_ids in target + { + "trigger": "scene.activated", + "target": { + "floor_id": ["floor-target-1", "floor-target-2"] + }, + }, + # Single label_id in target + { + "trigger": "scene.activated", + "target": {"label_id": "label-target-single"}, + }, + # Multiple label_ids in target + { + "trigger": "scene.activated", + "target": { + "label_id": ["label-target-1", "label-target-2"] + }, + }, + # Combined targets + { + "trigger": "scene.activated", + "target": { + "entity_id": "scene.combined_entity", + "device_id": "combined-device", + "area_id": "combined-area", + "floor_id": "combined-floor", + "label_id": "combined-label", + }, + }, + ], + "conditions": [], + "actions": [ + { + "action": "test.script", + "data": {"entity_id": "light.action_entity"}, + }, + ], + }, + ] + }, + ) + + # Test entity extraction from trigger targets + assert set(automation.entities_in_automation(hass, "automation.test1")) == { + "scene.target_entity", + "scene.target_entity_list1", + "scene.target_entity_list2", + "scene.combined_entity", + "light.action_entity", + } + + # Test device extraction from trigger targets + assert set(automation.devices_in_automation(hass, "automation.test1")) == { + trigger_device.id, + "target-device-1", + "target-device-2", + "combined-device", + } + + # Test area extraction from trigger targets + assert set(automation.areas_in_automation(hass, "automation.test1")) == { + "area-target-single", + "area-target-1", + "area-target-2", + "combined-area", + } + + # Test floor extraction from trigger targets + assert set(automation.floors_in_automation(hass, "automation.test1")) == { + "floor-target-single", + "floor-target-1", + "floor-target-2", + "combined-floor", + } + + # Test label extraction from trigger targets + assert set(automation.labels_in_automation(hass, "automation.test1")) == { + "label-target-single", + "label-target-1", + "label-target-2", + "combined-label", + } + + # Test automations_with_* functions + assert set(automation.automations_with_entity(hass, "scene.target_entity")) == { + "automation.test1" + } + assert set(automation.automations_with_device(hass, trigger_device.id)) == { + "automation.test1" + } + assert set(automation.automations_with_area(hass, "area-target-single")) == { + "automation.test1" + } + assert set(automation.automations_with_floor(hass, "floor-target-single")) == { + "automation.test1" + } + assert set(automation.automations_with_label(hass, "label-target-single")) == { + "automation.test1" + } + + async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None: """Test humanifying Automation Trigger event.""" hass.config.components.add("recorder") From 7127159a5b99cabd47a764cfe6431f38a044a352 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 7 Jan 2026 15:07:24 +0100 Subject: [PATCH 084/163] Make Watts depend on the cloud integration (#160424) --- homeassistant/components/watts/manifest.json | 2 +- tests/components/watts/conftest.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 40bcf375760..71fac5e6a69 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -3,7 +3,7 @@ "name": "Watts Vision +", "codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "cloud"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", "quality_scale": "bronze", diff --git a/tests/components/watts/conftest.py b/tests/components/watts/conftest.py index 6e78bc397b2..89a20cb56ab 100644 --- a/tests/components/watts/conftest.py +++ b/tests/components/watts/conftest.py @@ -101,3 +101,16 @@ def mock_config_entry() -> MockConfigEntry: entry_id="01J0BC4QM2YBRP6H5G933CETI8", unique_id=TEST_USER_ID, ) + + +@pytest.fixture(name="skip_cloud", autouse=True) +def skip_cloud_fixture(): + """Skip setting up cloud. + + Cloud already has its own tests for account link. + + We do not need to test it here as we only need to test our + usage of the oauth2 helpers. + """ + with patch("homeassistant.components.cloud.async_setup", return_value=True): + yield From 2c1bc961619f065c27db53c00947aff59f344e09 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 7 Jan 2026 16:07:11 +0100 Subject: [PATCH 085/163] Bump deebot-client to 17.0.1 (#160428) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index c16f12f3153..293a6fd3303 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85d58d2e594..44fd13ed268 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ debugpy==1.8.17 decora-wifi==1.4 # homeassistant.components.ecovacs -deebot-client==17.0.0 +deebot-client==17.0.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95d7fbbfaea..739088ae8d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -691,7 +691,7 @@ dbus-fast==3.1.2 debugpy==1.8.17 # homeassistant.components.ecovacs -deebot-client==17.0.0 +deebot-client==17.0.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 4465aa264c15bbe8f07d6c202a49021132101213 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jan 2026 17:34:23 +0100 Subject: [PATCH 086/163] Update frontend to 20260107.0 (#160434) --- 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 8047d3378a7..17140d49ac9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -23,5 +23,5 @@ "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20251229.1"] + "requirements": ["home-assistant-frontend==20260107.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b857bdb57e8..3cf5d23faff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.8.0 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20251229.1 +home-assistant-frontend==20260107.0 home-assistant-intents==2026.1.6 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44fd13ed268..5d1faeb37f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251229.1 +home-assistant-frontend==20260107.0 # homeassistant.components.conversation home-assistant-intents==2026.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 739088ae8d4..60310f77333 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251229.1 +home-assistant-frontend==20260107.0 # homeassistant.components.conversation home-assistant-intents==2026.1.6 From 1f28fe99337ebbdadc5822451c2124cc9346826a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jan 2026 17:46:04 +0100 Subject: [PATCH 087/163] Bump version to 2026.1.0 --- 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 b8811456c16..97c279aa6b0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0" __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 79be6e1e1c5..6ed0b6b41ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0b5" +version = "2026.1.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 50086ca5c735f15e3d06152618962866648b4b71 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Mon, 12 Jan 2026 00:04:30 -1000 Subject: [PATCH 088/163] Fix Hikvision NVR binary sensors not being detected (#160254) Co-authored-by: Claude Opus 4.5 --- .../components/hikvision/__init__.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py index e3089a6453c..da3cdcdf4de 100644 --- a/homeassistant/components/hikvision/__init__.py +++ b/homeassistant/components/hikvision/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass import logging +from pyhik.constants import SENSOR_MAP from pyhik.hikvision import HikCamera import requests @@ -70,13 +71,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> device_type=device_type, ) + _LOGGER.debug( + "Device %s (type=%s) initial event_states: %s", + device_name, + device_type, + camera.current_event_states, + ) + # For NVRs or devices with no detected events, try to fetch events from ISAPI + # Use broader notification methods for NVRs since they often use 'record' etc. if device_type == "NVR" or not camera.current_event_states: + nvr_notification_methods = {"center", "HTTP", "record", "email", "beep"} def fetch_and_inject_nvr_events() -> None: """Fetch and inject NVR events in a single executor job.""" - if nvr_events := camera.get_event_triggers(): - camera.inject_events(nvr_events) + nvr_events = camera.get_event_triggers(nvr_notification_methods) + _LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events) + if nvr_events: + # Map raw event type names to friendly names using SENSOR_MAP + mapped_events: dict[str, list[int]] = {} + for event_type, channels in nvr_events.items(): + friendly_name = SENSOR_MAP.get(event_type.lower(), event_type) + if friendly_name in mapped_events: + mapped_events[friendly_name].extend(channels) + else: + mapped_events[friendly_name] = list(channels) + _LOGGER.debug("Mapped NVR events: %s", mapped_events) + camera.inject_events(mapped_events) await hass.async_add_executor_job(fetch_and_inject_nvr_events) From 9e95b80805ff26dada474e5ff50ab85268f9d41d Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:30:05 +0100 Subject: [PATCH 089/163] Bump eheimdigital to 1.5.0 (#160312) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 72864406ecf..16913eb54de 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "platinum", - "requirements": ["eheimdigital==1.4.0"], + "requirements": ["eheimdigital==1.5.0"], "zeroconf": [ { "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 5d1faeb37f9..9cd43f34c77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -854,7 +854,7 @@ ecoaliface==0.4.0 egauge-async==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.4.0 +eheimdigital==1.5.0 # homeassistant.components.ekeybionyx ekey-bionyxpy==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60310f77333..4347b11afae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -754,7 +754,7 @@ easyenergy==2.1.2 egauge-async==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.4.0 +eheimdigital==1.5.0 # homeassistant.components.ekeybionyx ekey-bionyxpy==1.0.1 From ac447695395481ec27cf196e7f67da8c5bf2a315 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 7 Jan 2026 19:57:09 +0100 Subject: [PATCH 090/163] Bump ZHA to 0.0.84 (#160440) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7bb80c3f4ab..6e5252f46b7 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==0.0.83", "serialx==0.5.0"], + "requirements": ["zha==0.0.84", "serialx==0.5.0"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 9cd43f34c77..f61d432b8a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3277,7 +3277,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.83 +zha==0.0.84 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4347b11afae..df97c90899b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2738,7 +2738,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.83 +zha==0.0.84 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From b7519cd880245e50ca390861a00099b3e6cd4352 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 8 Jan 2026 08:41:30 +0100 Subject: [PATCH 091/163] Bump pyOverkiz to 1.19.4 (#160457) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 611bd4c0cea..a101c249c4c 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.19.3"], + "requirements": ["pyoverkiz==1.19.4"], "zeroconf": [ { "name": "gateway*", diff --git a/requirements_all.txt b/requirements_all.txt index f61d432b8a5..00ab5b24654 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2291,7 +2291,7 @@ pyotgw==2.2.2 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz==1.19.3 +pyoverkiz==1.19.4 # homeassistant.components.palazzetti pypalazzetti==0.1.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df97c90899b..f019fe04b9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ pyotgw==2.2.2 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz==1.19.3 +pyoverkiz==1.19.4 # homeassistant.components.palazzetti pypalazzetti==0.1.20 From 00ad44cb913a885e5361c09ca7f1cf98ee2be41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Fri, 9 Jan 2026 12:06:36 +0100 Subject: [PATCH 092/163] Fix JSON serialization of time objects in anthropic tool results (#160459) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/anthropic/entity.py | 3 +- .../snapshots/test_conversation.ambr | 60 ++++++++++++++++++- .../components/anthropic/test_conversation.py | 31 +++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 2c71c2527ed..0d07ae71432 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -69,6 +69,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity +from homeassistant.helpers.json import json_dumps from homeassistant.util import slugify from . import AnthropicConfigEntry @@ -193,7 +194,7 @@ def _convert_content( tool_result_block = ToolResultBlockParam( type="tool_result", tool_use_id=content.tool_call_id, - content=json.dumps(content.tool_result), + content=json_dumps(content.tool_result), ) external_tool = False if not messages or messages[-1]["role"] != ( diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index df5e2b3f5ac..acb4ced2c36 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -309,12 +309,12 @@ 'type': 'text', }), dict({ - 'content': '{"success": true, "response": "Lights are off."}', + 'content': '{"success":true,"response":"Lights are off."}', 'tool_use_id': 'mock-tool-call-id', 'type': 'tool_result', }), dict({ - 'content': '{"success": false, "response": "Not enough milk."}', + 'content': '{"success":false,"response":"Not enough milk."}', 'tool_use_id': 'mock-tool-call-id-2', 'type': 'tool_result', }), @@ -462,6 +462,62 @@ }), ]) # --- +# name: test_history_conversion[content6] + list([ + dict({ + 'content': 'What time is it?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Let me check the time for you.', + 'type': 'text', + }), + dict({ + 'id': 'mock-tool-call-id', + 'input': dict({ + }), + 'name': 'GetCurrentTime', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'content': '{"speech_slots":{"time":"14:30:00"},"message":"Current time retrieved"}', + 'tool_use_id': 'mock-tool-call-id', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'It is currently 2:30 PM.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_redacted_thinking list([ dict({ diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index cf5a3d17c82..ac54272fe0a 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +import datetime from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -317,7 +318,7 @@ async def test_function_exception( "role": "user", "content": [ { - "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}', + "content": '{"error":"HomeAssistantError","error_text":"Test tool exception"}', "tool_use_id": "toolu_0123456789AbCdEfGhIjKlM", "type": "tool_result", } @@ -893,6 +894,34 @@ async def test_web_search( ), ), ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What time is it?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="Let me check the time for you.", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="GetCurrentTime", + tool_args={}, + ), + ], + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude_conversation", + tool_call_id="mock-tool-call-id", + tool_name="GetCurrentTime", + tool_result={ + "speech_slots": {"time": datetime.time(14, 30, 0)}, + "message": "Current time retrieved", + }, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="It is currently 2:30 PM.", + ), + ], ], ) async def test_history_conversion( From 5ccdfda7476a9667ee3dadc89be845fae45c1206 Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:39:47 -0500 Subject: [PATCH 093/163] Add asyncio-level timeout to Backblaze B2 uploads (#160468) --- .../components/backblaze_b2/backup.py | 31 +++++-- tests/components/backblaze_b2/test_backup.py | 92 +++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index a9a15e634cf..f0acc5218bc 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__) # Cache TTL for backup list (in seconds) CACHE_TTL = 300 +# Timeout for upload operations (in seconds) +# This prevents uploads from hanging indefinitely +UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout) + def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: """Return the suggested filenames for the backup and metadata files.""" @@ -329,13 +333,28 @@ class BackblazeBackupAgent(BackupAgent): _LOGGER.debug("Uploading backup file %s with streaming", filename) try: content_type, _ = mimetypes.guess_type(filename) - file_version = await self._hass.async_add_executor_job( - self._upload_unbound_stream_sync, - reader, - filename, - content_type or "application/x-tar", - file_info, + file_version = await asyncio.wait_for( + self._hass.async_add_executor_job( + self._upload_unbound_stream_sync, + reader, + filename, + content_type or "application/x-tar", + file_info, + ), + timeout=UPLOAD_TIMEOUT, ) + except TimeoutError: + _LOGGER.error( + "Upload of %s timed out after %s seconds", filename, UPLOAD_TIMEOUT + ) + reader.abort() + raise BackupAgentError( + f"Upload timed out after {UPLOAD_TIMEOUT} seconds" + ) from None + except asyncio.CancelledError: + _LOGGER.warning("Upload of %s was cancelled", filename) + reader.abort() + raise finally: reader.close() diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py index bcf15337bd6..12f5894c49f 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze_b2/test_backup.py @@ -1,5 +1,6 @@ """Backblaze B2 backup agent tests.""" +import asyncio from collections.abc import AsyncGenerator from io import StringIO import json @@ -863,3 +864,94 @@ async def test_metadata_downloads_are_sequential( assert response["success"] # Verify downloads were sequential (max 1 at a time) assert max_concurrent == 1 + + +async def test_upload_timeout( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test upload timeout handling.""" + client = await hass_client() + + mock_file_info = Mock() + mock_file_info.delete = Mock() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch( + "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync", + ), + patch( + "homeassistant.components.backblaze_b2.backup.asyncio.wait_for", + side_effect=TimeoutError, + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=mock_file_info, + ), + caplog.at_level(logging.ERROR), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert any("timed out" in msg for msg in caplog.messages) + + +async def test_upload_cancelled( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test upload cancellation handling.""" + client = await hass_client() + + mock_file_info = Mock() + mock_file_info.delete = Mock() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch( + "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync", + ), + patch( + "homeassistant.components.backblaze_b2.backup.asyncio.wait_for", + side_effect=asyncio.CancelledError, + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=mock_file_info, + ), + caplog.at_level(logging.WARNING), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + # CancelledError propagates up and causes a 500 error + assert resp.status == 500 + assert any("cancelled" in msg for msg in caplog.messages) From 9aa5953a8610b2d6c370fff5cc91f37f65a8afd9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:55:39 +0100 Subject: [PATCH 094/163] Fix Requirement parsing in RequirementsManager (#160485) --- homeassistant/requirements.py | 18 ++++++++---- homeassistant/util/package.py | 55 ++++++++++++++++++++++------------- tests/test_requirements.py | 19 +++++++++--- 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 6023ed7a4e6..75d3fbf46d1 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -9,8 +9,6 @@ import logging import os from typing import Any -from packaging.requirements import Requirement - from .core import HomeAssistant, callback from .exceptions import HomeAssistantError from .helpers import singleton @@ -260,8 +258,13 @@ class RequirementsManager: """ if DEPRECATED_PACKAGES or self.hass.config.skip_pip_packages: all_requirements = { - requirement_string: Requirement(requirement_string) + requirement_string: requirement_details for requirement_string in requirements + if ( + requirement_details := pkg_util.parse_requirement_safe( + requirement_string + ) + ) } if DEPRECATED_PACKAGES: for requirement_string, requirement_details in all_requirements.items(): @@ -272,9 +275,12 @@ class RequirementsManager: "" if is_built_in else "custom ", name, f"has requirement '{requirement_string}' which {reason}", - f"This will stop working in Home Assistant {breaks_in_ha_version}, please" - if breaks_in_ha_version - else "Please", + ( + "This will stop working in Home Assistant " + f"{breaks_in_ha_version}, please" + if breaks_in_ha_version + else "Please" + ), async_suggest_report_issue( self.hass, integration_domain=name ), diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 2c0ed363eef..eebcdb2bba6 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -44,6 +44,39 @@ def get_installed_versions(specifiers: set[str]) -> set[str]: return {specifier for specifier in specifiers if is_installed(specifier)} +def parse_requirement_safe(requirement_str: str) -> Requirement | None: + """Parse a requirement string into a Requirement object. + + expected input is a pip compatible package specifier (requirement string) + e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" or "package@git+https://..." + + For backward compatibility, it also accepts a URL with a fragment + e.g. "git+https://github.com/pypa/pip#pip>=1" + + Returns None on a badly-formed requirement string. + """ + try: + return Requirement(requirement_str) + except InvalidRequirement: + if "#" not in requirement_str: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return None + + # This is likely a URL with a fragment + # example: git+https://github.com/pypa/pip#pip>=1 + + # fragment support was originally used to install zip files, and + # we no longer do this in Home Assistant. However, custom + # components started using it to install packages from git + # urls which would make it would be a breaking change to + # remove it. + try: + return Requirement(urlparse(requirement_str).fragment) + except InvalidRequirement: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return None + + def is_installed(requirement_str: str) -> bool: """Check if a package is installed and will be loaded when we import it. @@ -56,26 +89,8 @@ def is_installed(requirement_str: str) -> bool: Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req. """ - try: - req = Requirement(requirement_str) - except InvalidRequirement: - if "#" not in requirement_str: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False - - # This is likely a URL with a fragment - # example: git+https://github.com/pypa/pip#pip>=1 - - # fragment support was originally used to install zip files, and - # we no longer do this in Home Assistant. However, custom - # components started using it to install packages from git - # urls which would make it would be a breaking change to - # remove it. - try: - req = Requirement(urlparse(requirement_str).fragment) - except InvalidRequirement: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False + if (req := parse_requirement_safe(requirement_str)) is None: + return False try: if (installed_version := version(req.name)) is None: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bb44f9df41a..0b9dc1c8a79 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -661,11 +661,12 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("requirement", "is_built_in", "deprecation_info"), + ("requirement", "is_built_in", "deprecation_prefix", "deprecation_info"), [ ( "hello", True, + "Detected that integration", "which is deprecated for testing. This will stop working in Home Assistant" " 2020.12, please create a bug report at https://github.com/home-assistant/" "core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22", @@ -673,6 +674,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "hello>=1.0.0", False, + "Detected that custom integration", "which is deprecated for testing. This will stop working in Home Assistant" " 2020.12, please create a bug report at https://github.com/home-assistant/" "core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22", @@ -680,6 +682,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "pyserial-asyncio", False, + "Detected that custom integration", "which should be replaced by pyserial-asyncio-fast. This will stop" " working in Home Assistant 2026.7, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" @@ -688,6 +691,7 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ( "pyserial-asyncio>=0.6", True, + "Detected that integration", "which should be replaced by pyserial-asyncio-fast. This will stop" " working in Home Assistant 2026.7, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" @@ -699,6 +703,7 @@ async def test_install_deprecated_package( hass: HomeAssistant, requirement: str, is_built_in: bool, + deprecation_prefix: str, deprecation_info: str, caplog: pytest.LogCaptureFixture, ) -> None: @@ -710,10 +715,16 @@ async def test_install_deprecated_package( patch("homeassistant.util.package.install_package", return_value=True), ): await async_process_requirements( - hass, "test_component", [requirement], is_built_in + hass, + "test_component", + [ + requirement, + "git+https://github.com/user/project.git@1.2.3", + ], + is_built_in, ) assert ( - f"Detected that {'' if is_built_in else 'custom '}integration " - f"'test_component' has requirement '{requirement}' {deprecation_info}" + f"{deprecation_prefix} 'test_component'" + f" has requirement '{requirement}' {deprecation_info}" ) in caplog.text From 8d95511650e7803c5d37bd780a286a601f847ef8 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:24:00 +0200 Subject: [PATCH 095/163] Add Nettleie optimization option (#160494) --- homeassistant/components/osoenergy/sensor.py | 2 +- homeassistant/components/osoenergy/strings.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index 18859627952..c2b1e75cd70 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -55,7 +55,7 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = { key="optimization_mode", translation_key="optimization_mode", device_class=SensorDeviceClass.ENUM, - options=["off", "oso", "gridcompany", "smartcompany", "advanced"], + options=["off", "oso", "gridcompany", "smartcompany", "advanced", "nettleie"], value_fn=lambda entity_data: entity_data.state.lower(), ), "power_load": OSOEnergySensorEntityDescription( diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index b463c1d4d3f..4b48f084fb7 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -58,6 +58,7 @@ "state": { "advanced": "Advanced", "gridcompany": "Grid company", + "nettleie": "Nettleie", "off": "[%key:common::state::off%]", "oso": "OSO", "smartcompany": "Smart company" From 8748d6f200142791221bb344014d67e1e629c4b0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Jan 2026 12:10:39 +0100 Subject: [PATCH 096/163] Bump python-otbr-api to 2.7.1 (#160496) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index f4029f4aa9e..57749615270 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.7.0"] + "requirements": ["python-otbr-api==2.7.1"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 22d55f57d48..f6316bb6b46 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.7.1", "pyroute2==0.7.5"], "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 00ab5b24654..e3e8ab559cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2563,7 +2563,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.7.0 +python-otbr-api==2.7.1 # homeassistant.components.overseerr python-overseerr==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f019fe04b9f..438df5c68c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2150,7 +2150,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.7.0 +python-otbr-api==2.7.1 # homeassistant.components.overseerr python-overseerr==0.8.0 From 7b53b8691c5d866048005af438ee54eb7c7b3acb Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 8 Jan 2026 12:19:40 +0100 Subject: [PATCH 097/163] fix rain sensor for some rare velux windows (#160504) --- .../components/velux/binary_sensor.py | 8 +++++--- tests/components/velux/test_binary_sensor.py | 20 ++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 2b7d9f71579..22bec674b64 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -74,6 +74,8 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity): self._attr_available = True - # 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} + # Velux windows with rain sensors report an opening limitation when rain is detected. + # So far we've seen 89, 91, 93 (most cases) or 100 (Velux GPU). It probably makes sense to + # assume that any large enough limitation (we use >=89) means rain is detected. + # Documentation on this is non-existent AFAIK. + self._attr_is_on = limitation.min_value >= 89 diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 776b0301d4b..e289e79855d 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -49,15 +49,29 @@ async def test_rain_sensor_state( assert state is not None assert state.state == STATE_ON - # simulate rain detected (other Velux models report 93) + # simulate rain detected (most Velux models report 93) mock_window.get_limitation.return_value.min_value = 93 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 89) + mock_window.get_limitation.return_value.min_value = 89 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON + + # simulate other limits which do not indicate rain detected + mock_window.get_limitation.return_value.min_value = 88 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + # simulate no rain detected again - mock_window.get_limitation.return_value.min_value = 95 + mock_window.get_limitation.return_value.min_value = 0 await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None @@ -144,7 +158,7 @@ async def test_rain_sensor_unavailability( # Simulate recovery mock_window.get_limitation.side_effect = None - mock_window.get_limitation.return_value.min_value = 95 + mock_window.get_limitation.return_value.min_value = 0 await update_polled_entities(hass, freezer) # Entity should be available again From 34438bd039dc304b07afb6aff80e716d0084f910 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 9 Jan 2026 11:43:33 +0100 Subject: [PATCH 098/163] Fix trigger selectors (#160519) --- .../components/climate/triggers.yaml | 9 ++-- .../components/humidifier/triggers.yaml | 9 ++-- homeassistant/components/light/triggers.yaml | 9 ++-- homeassistant/helpers/trigger.py | 2 +- tests/helpers/test_trigger.py | 50 +++++++++---------- 5 files changed, 41 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index 0614e0ccd36..6dc7c59b81a 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -19,6 +19,10 @@ selector: choose: choices: + number: + selector: + number: + mode: box entity: selector: entity: @@ -27,14 +31,11 @@ - input_number - number - sensor - number: - selector: - number: - mode: box translation_key: number_or_entity .trigger_threshold_type: &trigger_threshold_type required: true + default: above selector: select: options: diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index ea58ece9698..23e8986ba6b 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -19,6 +19,10 @@ selector: choose: choices: + number: + selector: + number: + mode: box entity: selector: entity: @@ -27,14 +31,11 @@ - input_number - number - sensor - number: - selector: - number: - mode: box translation_key: number_or_entity .trigger_threshold_type: &trigger_threshold_type required: true + default: above selector: select: options: diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml index 87f720d6404..75843ea1a53 100644 --- a/homeassistant/components/light/triggers.yaml +++ b/homeassistant/components/light/triggers.yaml @@ -19,6 +19,10 @@ selector: choose: choices: + number: + selector: + number: + mode: box entity: selector: entity: @@ -27,10 +31,6 @@ - input_number - number - sensor - number: - selector: - number: - mode: box translation_key: number_or_entity turned_on: *trigger_common @@ -48,6 +48,7 @@ brightness_crossed_threshold: behavior: *trigger_behavior threshold_type: required: true + default: above selector: select: options: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index bffc01ecfd7..baa0e379de0 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -683,7 +683,7 @@ NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.exten ), vol.Optional(CONF_LOWER_LIMIT): _number_or_entity, vol.Optional(CONF_UPPER_LIMIT): _number_or_entity, - vol.Required(CONF_THRESHOLD_TYPE): ThresholdType, + vol.Required(CONF_THRESHOLD_TYPE): vol.Coerce(ThresholdType), }, _validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT), _validate_limits_for_threshold_type, diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 0d1400bcbd1..092ae05d96a 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -40,7 +40,6 @@ from homeassistant.helpers.trigger import ( CONF_UPPER_LIMIT, DATA_PLUGGABLE_ACTIONS, PluggableAction, - ThresholdType, Trigger, TriggerActionRunner, _async_get_trigger_platform, @@ -1387,25 +1386,26 @@ async def test_numerical_state_attribute_changed_error_handling( ("trigger_options", "expected_result"), [ # Valid configurations + # Don't use the enum in tests to allow testing validation of strings when the source is JSON or YAML ( - {CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: 10}, + {CONF_THRESHOLD_TYPE: "above", CONF_LOWER_LIMIT: 10}, does_not_raise(), ), ( - {CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: "sensor.test"}, + {CONF_THRESHOLD_TYPE: "above", CONF_LOWER_LIMIT: "sensor.test"}, does_not_raise(), ), ( - {CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: 90}, + {CONF_THRESHOLD_TYPE: "below", CONF_UPPER_LIMIT: 90}, does_not_raise(), ), ( - {CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: "sensor.test"}, + {CONF_THRESHOLD_TYPE: "below", CONF_UPPER_LIMIT: "sensor.test"}, does_not_raise(), ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, }, @@ -1413,7 +1413,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: "sensor.test", }, @@ -1421,7 +1421,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_LOWER_LIMIT: "sensor.test", CONF_UPPER_LIMIT: 90, }, @@ -1429,7 +1429,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_LOWER_LIMIT: "sensor.test", CONF_UPPER_LIMIT: "sensor.test", }, @@ -1437,7 +1437,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_THRESHOLD_TYPE: "outside", CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: 90, }, @@ -1445,7 +1445,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_THRESHOLD_TYPE: "outside", CONF_LOWER_LIMIT: 10, CONF_UPPER_LIMIT: "sensor.test", }, @@ -1453,7 +1453,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_THRESHOLD_TYPE: "outside", CONF_LOWER_LIMIT: "sensor.test", CONF_UPPER_LIMIT: 90, }, @@ -1461,7 +1461,7 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( { - CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_THRESHOLD_TYPE: "outside", CONF_LOWER_LIMIT: "sensor.test", CONF_UPPER_LIMIT: "sensor.test", }, @@ -1481,58 +1481,58 @@ async def test_numerical_state_attribute_changed_error_handling( ), ( # Must provide lower limit for ABOVE - {CONF_THRESHOLD_TYPE: ThresholdType.ABOVE}, + {CONF_THRESHOLD_TYPE: "above"}, pytest.raises(vol.Invalid), ), ( # Must provide lower limit for ABOVE - {CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_UPPER_LIMIT: 90}, + {CONF_THRESHOLD_TYPE: "above", CONF_UPPER_LIMIT: 90}, pytest.raises(vol.Invalid), ), ( # Must provide upper limit for BELOW - {CONF_THRESHOLD_TYPE: ThresholdType.BELOW}, + {CONF_THRESHOLD_TYPE: "below"}, pytest.raises(vol.Invalid), ), ( # Must provide upper limit for BELOW - {CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_LOWER_LIMIT: 10}, + {CONF_THRESHOLD_TYPE: "below", CONF_LOWER_LIMIT: 10}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for BETWEEN - {CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN}, + {CONF_THRESHOLD_TYPE: "between"}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for BETWEEN - {CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_LOWER_LIMIT: 10}, + {CONF_THRESHOLD_TYPE: "between", CONF_LOWER_LIMIT: 10}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for BETWEEN - {CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_UPPER_LIMIT: 90}, + {CONF_THRESHOLD_TYPE: "between", CONF_UPPER_LIMIT: 90}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for OUTSIDE - {CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE}, + {CONF_THRESHOLD_TYPE: "outside"}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for OUTSIDE - {CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_LOWER_LIMIT: 10}, + {CONF_THRESHOLD_TYPE: "outside", CONF_LOWER_LIMIT: 10}, pytest.raises(vol.Invalid), ), ( # Must provide upper and lower limits for OUTSIDE - {CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_UPPER_LIMIT: 90}, + {CONF_THRESHOLD_TYPE: "outside", CONF_UPPER_LIMIT: 90}, pytest.raises(vol.Invalid), ), ( # Must be valid entity id { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_ABOVE: "cat", CONF_BELOW: "dog", }, @@ -1541,7 +1541,7 @@ async def test_numerical_state_attribute_changed_error_handling( ( # Above must be smaller than below { - CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_THRESHOLD_TYPE: "between", CONF_ABOVE: 90, CONF_BELOW: 10, }, From 651b7116ddfd042910cb006901f1858121ebaab5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 8 Jan 2026 16:44:21 +0100 Subject: [PATCH 099/163] Bump Intergas Incomfort-client to v0.6.11 (#160520) --- homeassistant/components/incomfort/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 850f954f3a3..ea1a460a0cb 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["incomfortclient"], "quality_scale": "platinum", - "requirements": ["incomfort-client==0.6.10"] + "requirements": ["incomfort-client==0.6.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index e3e8ab559cb..4c152332655 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1282,7 +1282,7 @@ imeon_inverter_api==0.4.0 imgw_pib==1.6.0 # homeassistant.components.incomfort -incomfort-client==0.6.10 +incomfort-client==0.6.11 # homeassistant.components.influxdb influxdb-client==1.48.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 438df5c68c7..4bd60148a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1128,7 +1128,7 @@ imeon_inverter_api==0.4.0 imgw_pib==1.6.0 # homeassistant.components.incomfort -incomfort-client==0.6.10 +incomfort-client==0.6.11 # homeassistant.components.influxdb influxdb-client==1.48.0 From 07bc5d5c6b272adf166279d4b716eee46a519c01 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 8 Jan 2026 10:25:46 -0600 Subject: [PATCH 100/163] Revert "Update voluptuous and voluptuous-openapi" (#160530) --- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 4 ++-- requirements.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3cf5d23faff..788e60f96eb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,9 +70,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.3.0 +voluptuous-openapi==0.2.0 voluptuous-serialize==2.7.0 -voluptuous==0.16.0 +voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 diff --git a/pyproject.toml b/pyproject.toml index 6ed0b6b41ca..636bba559d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,9 @@ dependencies = [ "ulid-transform==1.5.2", "urllib3>=2.0", "uv==0.9.17", - "voluptuous==0.16.0", + "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", - "voluptuous-openapi==0.3.0", + "voluptuous-openapi==0.2.0", "yarl==1.22.0", "webrtc-models==0.3.0", "zeroconf==0.148.0", diff --git a/requirements.txt b/requirements.txt index a3c9da9a77c..847fb6564d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,9 +54,9 @@ typing-extensions>=4.15.0,<5.0 ulid-transform==1.5.2 urllib3>=2.0 uv==0.9.17 -voluptuous-openapi==0.3.0 +voluptuous-openapi==0.2.0 voluptuous-serialize==2.7.0 -voluptuous==0.16.0 +voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.22.0 zeroconf==0.148.0 From bb9fd9443040e6795ad4a8917248fbbb63e154b9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:10:29 -0500 Subject: [PATCH 101/163] Bump serialx to v0.6.2 (#160545) --- homeassistant/components/homeassistant_hardware/manifest.json | 2 +- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index ab1906c92b3..5385d627e0f 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "serialx==0.5.0", + "serialx==0.6.2", "universal-silabs-flasher==0.1.2", "ha-silabs-firmware-client==0.3.0" ] diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6e5252f46b7..e39e1d51ad1 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==0.0.84", "serialx==0.5.0"], + "requirements": ["zha==0.0.84", "serialx==0.6.2"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 4c152332655..67c9ef5645c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2842,7 +2842,7 @@ sentry-sdk==1.45.1 # homeassistant.components.homeassistant_hardware # homeassistant.components.zha -serialx==0.5.0 +serialx==0.6.2 # homeassistant.components.sfr_box sfrbox-api==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bd60148a8b..248b1fb39da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2381,7 +2381,7 @@ sentry-sdk==1.45.1 # homeassistant.components.homeassistant_hardware # homeassistant.components.zha -serialx==0.5.0 +serialx==0.6.2 # homeassistant.components.sfr_box sfrbox-api==0.1.0 From 6e380bafcaa1af5827d4b91ea8e7eef0cfb978ed Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 12 Jan 2026 01:46:30 +1000 Subject: [PATCH 102/163] Catch any migration failures in Teslemetry (#160549) --- homeassistant/components/teslemetry/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 5513e2b625c..f3a5ab8ef71 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -5,6 +5,7 @@ from collections.abc import Callable from typing import Final from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ClientError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, @@ -315,7 +316,7 @@ async def async_migrate_entry( data = await Teslemetry(session, access_token).migrate_to_oauth( CLIENT_ID, access_token, hass.config.location_name ) - except ClientResponseError as e: + except (ClientError, TypeError) as e: raise ConfigEntryAuthFailed from e # Add auth_implementation for OAuth2 flow compatibility From e5624b12246ff3fccd4bd71a2e0f61179c668a51 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 9 Jan 2026 10:55:33 +0100 Subject: [PATCH 103/163] Fix AttributeError for missing/incomplete health data in Tractive (#160553) --- homeassistant/components/tractive/__init__.py | 4 +- tests/components/tractive/test_init.py | 57 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index d60c4b657cb..a9c4931a295 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -338,8 +338,8 @@ class TractiveClient: # Handle both structures for compatibility data = event.get("content", event) - activity = data.get("activity", {}) - sleep = data.get("sleep", {}) + activity = data.get("activity") or {} + sleep = data.get("sleep") or {} payload = { ATTR_DAILY_GOAL: activity.get("minutesGoal"), diff --git a/tests/components/tractive/test_init.py b/tests/components/tractive/test_init.py index 3387232b231..24733868aee 100644 --- a/tests/components/tractive/test_init.py +++ b/tests/components/tractive/test_init.py @@ -1,11 +1,19 @@ """Test init of Tractive integration.""" +from typing import Any from unittest.mock import AsyncMock, patch from aiotractive.exceptions import TractiveError, UnauthorizedError import pytest -from homeassistant.components.tractive.const import DOMAIN +from homeassistant.components.tractive.const import ( + ATTR_DAILY_GOAL, + ATTR_MINUTES_ACTIVE, + ATTR_MINUTES_DAY_SLEEP, + ATTR_MINUTES_NIGHT_SLEEP, + ATTR_MINUTES_REST, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -161,3 +169,50 @@ async def test_server_unavailable( await hass.async_block_till_done() assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize(("sleep_data"), [None, {}, {"unexpected": 123}]) +async def test_missing_sleep_data( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, + sleep_data: dict[str, Any] | None, +) -> None: + """Test for missing sleep data.""" + event = {"petId": "pet_id_123", "sleep": sleep_data} + + await init_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.tractive.async_dispatcher_send" + ) as async_dispatcher_send_mock: + mock_tractive_client.send_health_overview_event(mock_config_entry, event) + + assert async_dispatcher_send_mock.call_count == 1 + payload = async_dispatcher_send_mock.mock_calls[0][1][2] + assert payload[ATTR_MINUTES_DAY_SLEEP] is None + assert payload[ATTR_MINUTES_NIGHT_SLEEP] is None + assert payload[ATTR_MINUTES_REST] is None + + +@pytest.mark.parametrize(("activity_data"), [None, {}, {"unexpected": 123}]) +async def test_missing_activity_data( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, + activity_data: dict[str, Any] | None, +) -> None: + """Test for missing activity data.""" + event = {"petId": "pet_id_123", "activity": activity_data} + + await init_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.tractive.async_dispatcher_send" + ) as async_dispatcher_send_mock: + mock_tractive_client.send_health_overview_event(mock_config_entry, event) + + assert async_dispatcher_send_mock.call_count == 1 + payload = async_dispatcher_send_mock.mock_calls[0][1][2] + assert payload[ATTR_DAILY_GOAL] is None + assert payload[ATTR_MINUTES_ACTIVE] is None From c1e7122d1cb0c4afd15d6d993caa84b6ffc13ecb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 8 Jan 2026 17:09:18 -0600 Subject: [PATCH 104/163] Bump pysilero-vad to 3.1.0 (#160554) --- homeassistant/components/assist_pipeline/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index dd9e426830b..98804877c2e 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"] + "requirements": ["pysilero-vad==3.1.0", "pyspeex-noise==1.0.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 788e60f96eb..54489d97a9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -56,7 +56,7 @@ PyJWT==2.10.1 PyNaCl==1.6.0 pyOpenSSL==25.3.0 pyserial==3.5 -pysilero-vad==3.0.1 +pysilero-vad==3.1.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements.txt b/requirements.txt index 847fb6564d1..d7f3ca26675 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pyOpenSSL==25.3.0 -pysilero-vad==3.0.1 +pysilero-vad==3.1.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 67c9ef5645c..86cf8944866 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2409,7 +2409,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.0.1 +pysilero-vad==3.1.0 # homeassistant.components.sky_hub pyskyqhub==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 248b1fb39da..041071dba00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2032,7 +2032,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.0.1 +pysilero-vad==3.1.0 # homeassistant.components.sma pysma==1.0.2 From 2af1fc6759ab8e8aa062e1cdd3200cf7b557e6a0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:52:44 +0100 Subject: [PATCH 105/163] Fix for older Fritzbox models which do not support smarthome triggers (#160555) --- .../components/fritzbox/coordinator.py | 11 ++++++-- tests/components/fritzbox/test_coordinator.py | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 256c1258c38..1f1a2097efc 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -77,9 +77,14 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ) LOGGER.debug("enable smarthome templates: %s", self.has_templates) - self.has_triggers = await self.hass.async_add_executor_job( - self.fritz.has_triggers - ) + try: + self.has_triggers = await self.hass.async_add_executor_job( + self.fritz.has_triggers + ) + except HTTPError: + # Fritz!OS < 7.39 just don't have this api endpoint + # so we need to fetch the HTTPError here and assume no triggers + self.has_triggers = False LOGGER.debug("enable smarthome triggers: %s", self.has_triggers) self.configuration_url = self.fritz.get_prefixed_host() diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 794d6ac4397..37ac5f40440 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -6,9 +6,11 @@ from datetime import timedelta from unittest.mock import Mock from pyfritzhome import LoginError +import pytest from requests.exceptions import ConnectionError, HTTPError from homeassistant.components.fritzbox.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICES from homeassistant.core import HomeAssistant @@ -20,6 +22,8 @@ from . import ( FritzDeviceSensorMock, FritzDeviceSwitchMock, FritzEntityBaseMock, + FritzTriggerMock, + setup_config_entry, ) from .const import MOCK_CONFIG @@ -184,3 +188,27 @@ async def test_coordinator_workaround_sub_units_without_main_device( assert len(device_entries) == 2 assert device_entries[0].identifiers == {(DOMAIN, "good_device")} assert device_entries[1].identifiers == {(DOMAIN, "bad_device")} + + +@pytest.mark.parametrize( + ("trigger", "side_effect", "switch_entity_count"), + [ + (None, None, 0), + (None, HTTPError(), 0), + (FritzTriggerMock(), None, 1), + ], +) +async def test_coordinator_has_triggers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + fritz: Mock, + trigger: Mock | None, + side_effect: Exception | None, + switch_entity_count: int, +) -> None: + """Test coordinator has_triggers property.""" + fritz().has_triggers.side_effect = side_effect + assert await setup_config_entry( + hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], fritz=fritz, trigger=trigger + ) + assert len(hass.states.async_all(SWITCH_DOMAIN)) == switch_entity_count From 395f0ad2a718d790c0f160b3a5f23c6856ff6388 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:40:38 +0100 Subject: [PATCH 106/163] Bump google-air-quality-api to 2.1.2 (#160561) --- homeassistant/components/google_air_quality/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_air_quality/manifest.json b/homeassistant/components/google_air_quality/manifest.json index 084151d2667..05b7fe1ae64 100644 --- a/homeassistant/components/google_air_quality/manifest.json +++ b/homeassistant/components/google_air_quality/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["google_air_quality_api"], "quality_scale": "bronze", - "requirements": ["google_air_quality_api==2.0.2"] + "requirements": ["google_air_quality_api==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86cf8944866..40a68ca3abe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2 google-photos-library-api==0.12.1 # homeassistant.components.google_air_quality -google_air_quality_api==2.0.2 +google_air_quality_api==2.1.2 # homeassistant.components.slide # homeassistant.components.slide_local diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 041071dba00..927d87cde55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ google-nest-sdm==9.1.2 google-photos-library-api==0.12.1 # homeassistant.components.google_air_quality -google_air_quality_api==2.0.2 +google_air_quality_api==2.1.2 # homeassistant.components.slide # homeassistant.components.slide_local From f59566d20b25959f0c8343b661d283a924adc427 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 9 Jan 2026 19:41:18 +1000 Subject: [PATCH 107/163] Fix Climate signal in Teslemetry (#160571) --- homeassistant/components/teslemetry/climate.py | 12 +++++++----- tests/components/teslemetry/test_climate.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 000e1b136c8..15965044771 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -291,9 +291,7 @@ class TeslemetryStreamingClimateEntity( ) ) self.async_on_remove( - self.vehicle.stream_vehicle.listen_HvacACEnabled( - self._async_handle_hvac_ac_enabled - ) + self.vehicle.stream_vehicle.listen_HvacPower(self._async_handle_hvac_power) ) self.async_on_remove( self.vehicle.stream_vehicle.listen_ClimateKeeperMode( @@ -335,9 +333,13 @@ class TeslemetryStreamingClimateEntity( self._attr_current_temperature = data self.async_write_ha_state() - def _async_handle_hvac_ac_enabled(self, data: bool | None): + def _async_handle_hvac_power(self, data: str | None): self._attr_hvac_mode = ( - None if data is None else HVACMode.HEAT_COOL if data else HVACMode.OFF + None + if data is None + else HVACMode.HEAT_COOL + if data == "On" + else HVACMode.OFF ) self.async_write_ha_state() diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index f6c158fbd80..632247c38f8 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -321,7 +321,7 @@ async def test_select_streaming( "vin": VEHICLE_DATA_ALT["response"]["vin"], "data": { Signal.INSIDE_TEMP: 26, - Signal.HVAC_AC_ENABLED: True, + Signal.HVAC_POWER: "HvacPowerStateOn", Signal.CLIMATE_KEEPER_MODE: "ClimateKeeperModeOn", Signal.RIGHT_HAND_DRIVE: True, Signal.HVAC_LEFT_TEMPERATURE_REQUEST: 22, From 0143c4ff85890e81eadf71adf73f920e3ae491be Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 9 Jan 2026 11:14:15 +0200 Subject: [PATCH 108/163] Bump pysma to 1.1.0 (#160583) --- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index ea0c1793fe2..bc74147e12e 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -16,5 +16,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pysma"], - "requirements": ["pysma==1.0.2"] + "requirements": ["pysma==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 40a68ca3abe..08b40710104 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2415,7 +2415,7 @@ pysilero-vad==3.1.0 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==1.0.2 +pysma==1.1.0 # homeassistant.components.smappee pysmappee==0.2.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 927d87cde55..12c1aec9431 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2035,7 +2035,7 @@ pysignalclirestapi==0.3.24 pysilero-vad==3.1.0 # homeassistant.components.sma -pysma==1.0.2 +pysma==1.1.0 # homeassistant.components.smappee pysmappee==0.2.29 From 2975b3c1b99a7935609b0e08c4de1f7aa56c8e11 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 9 Jan 2026 01:51:39 -0800 Subject: [PATCH 109/163] Bump opower to 0.16.1 (#160588) --- 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 7bc105c3474..036e4c2b973 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.16.0"] + "requirements": ["opower==0.16.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08b40710104..7998e0c60c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.16.0 +opower==0.16.1 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12c1aec9431..e19191dce29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.16.0 +opower==0.16.1 # homeassistant.components.oralb oralb-ble==1.0.2 From 94ff8818977946d6e67f42a0f531434a0fdf9404 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 9 Jan 2026 19:41:33 +1000 Subject: [PATCH 110/163] Fix config flow bug in Tesla Fleet (#160591) --- homeassistant/components/tesla_fleet/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index 48eb736ae56..ebbb22b945e 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -79,6 +79,7 @@ class OAuth2FlowHandler( session = async_get_clientsession(self.hass) self.api = TeslaFleetApi( + access_token="", session=session, server=server, partner_scope=True, From 2bf4ac20ea7a9fece7e0d21252b002361a9dacb8 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:42:23 +0100 Subject: [PATCH 111/163] Add missing segment speed icons for WLED (#160597) --- homeassistant/components/wled/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/wled/icons.json b/homeassistant/components/wled/icons.json index db3cbe573bf..a4e8fa1092a 100644 --- a/homeassistant/components/wled/icons.json +++ b/homeassistant/components/wled/icons.json @@ -9,6 +9,9 @@ } }, "number": { + "segment_speed": { + "default": "mdi:speedometer" + }, "speed": { "default": "mdi:speedometer" } From ea9cd7d905cca2e26b1f3a338669a69a0fae2fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 11 Jan 2026 11:40:27 +0100 Subject: [PATCH 112/163] Better handling of ratelimiting from Tibber (#160599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/coordinator.py | 8 +++++++- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 84fac8237c0..39fca55238c 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -250,6 +250,12 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): async def _async_update_data(self) -> dict[str, TibberDevice]: """Fetch the latest device capabilities from the Tibber Data API.""" client = await self._async_get_client() - devices: dict[str, TibberDevice] = await client.update_devices() + try: + devices: dict[str, TibberDevice] = await client.update_devices() + except tibber.exceptions.RateLimitExceededError as err: + raise UpdateFailed( + f"Rate limit exceeded, retry after {err.retry_after} seconds", + retry_after=err.retry_after, + ) from err self._build_sensor_lookup(devices) return devices diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 9388d413c04..127cc878bdc 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.34.1"] + "requirements": ["pyTibber==0.34.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7998e0c60c1..e39a0b188e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.34.1 +pyTibber==0.34.4 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e19191dce29..032fd4786b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.34.1 +pyTibber==0.34.4 # homeassistant.components.dlink pyW215==0.8.0 From 50c477a408ba0ac4bb5d5a94d078aea2d226e67b Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:48:00 +0100 Subject: [PATCH 113/163] Change device class to energy_storage for some enphase_envoy battery entities (#160603) --- .../components/enphase_envoy/sensor.py | 6 ++-- .../snapshots/test_diagnostics.ambr | 12 +++---- .../enphase_envoy/snapshots/test_sensor.ambr | 36 +++++++++---------- tests/components/enphase_envoy/test_sensor.py | 32 +++++++++++++++++ 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 89b6df97507..fe9a30f9e95 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -783,7 +783,7 @@ ENCHARGE_AGGREGATE_SENSORS = ( translation_key="available_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.ENERGY, + device_class=SensorDeviceClass.ENERGY_STORAGE, value_fn=attrgetter("available_energy"), ), EnvoyEnchargeAggregateSensorEntityDescription( @@ -791,14 +791,14 @@ ENCHARGE_AGGREGATE_SENSORS = ( translation_key="reserve_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.ENERGY, + device_class=SensorDeviceClass.ENERGY_STORAGE, value_fn=attrgetter("backup_reserve"), ), EnvoyEnchargeAggregateSensorEntityDescription( key="max_capacity", translation_key="max_capacity", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, + device_class=SensorDeviceClass.ENERGY_STORAGE, value_fn=attrgetter("max_available_capacity"), ), ) diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 58a37f120db..799c435058c 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -9432,7 +9432,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': 'energy', + 'original_device_class': 'energy_storage', 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', @@ -9445,7 +9445,7 @@ }), 'state': dict({ 'attributes': dict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy <> Available battery energy', 'state_class': 'measurement', 'unit_of_measurement': 'Wh', @@ -9482,7 +9482,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': 'energy', + 'original_device_class': 'energy_storage', 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', @@ -9495,7 +9495,7 @@ }), 'state': dict({ 'attributes': dict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy <> Reserve battery energy', 'state_class': 'measurement', 'unit_of_measurement': 'Wh', @@ -9530,7 +9530,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': 'energy', + 'original_device_class': 'energy_storage', 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', @@ -9543,7 +9543,7 @@ }), 'state': dict({ 'attributes': dict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy <> Battery capacity', 'unit_of_measurement': 'Wh', }), diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index b78151bbe4f..189a9dd62d5 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -3802,7 +3802,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', @@ -3817,7 +3817,7 @@ # name: test_sensor[envoy_acb_batt][sensor.envoy_1234_available_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Available battery energy', 'state_class': , 'unit_of_measurement': , @@ -3968,7 +3968,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', @@ -3983,7 +3983,7 @@ # name: test_sensor[envoy_acb_batt][sensor.envoy_1234_battery_capacity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Battery capacity', 'unit_of_measurement': , }), @@ -7480,7 +7480,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', @@ -7495,7 +7495,7 @@ # name: test_sensor[envoy_acb_batt][sensor.envoy_1234_reserve_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Reserve battery energy', 'state_class': , 'unit_of_measurement': , @@ -9055,7 +9055,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', @@ -9070,7 +9070,7 @@ # name: test_sensor[envoy_eu_batt][sensor.envoy_1234_available_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Available battery energy', 'state_class': , 'unit_of_measurement': , @@ -9221,7 +9221,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', @@ -9236,7 +9236,7 @@ # name: test_sensor[envoy_eu_batt][sensor.envoy_1234_battery_capacity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Battery capacity', 'unit_of_measurement': , }), @@ -12733,7 +12733,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', @@ -12748,7 +12748,7 @@ # name: test_sensor[envoy_eu_batt][sensor.envoy_1234_reserve_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Reserve battery energy', 'state_class': , 'unit_of_measurement': , @@ -14711,7 +14711,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', @@ -14726,7 +14726,7 @@ # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_available_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Available battery energy', 'state_class': , 'unit_of_measurement': , @@ -15054,7 +15054,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', @@ -15069,7 +15069,7 @@ # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_battery_capacity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Battery capacity', 'unit_of_measurement': , }), @@ -21725,7 +21725,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', @@ -21740,7 +21740,7 @@ # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_reserve_battery_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Reserve battery energy', 'state_class': , 'unit_of_measurement': , diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index f7a7970971a..d8d19ec6a45 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -1264,3 +1264,35 @@ async def test_fw_update( assert "firmware changed from: " in caplog.text assert "to: 0.0.0, reloading enphase envoy integration" in caplog.text + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy", + "envoy_1p_metered", + "envoy_eu_batt", + "envoy_metered_batt_relay", + "envoy_nobatt_metered_3p", + "envoy_tot_cons_metered", + "envoy_acb_batt", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_state_class_warnings( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enphase_envoy sensor creation does not result in deviceclass/state_class warnings.""" + logging.getLogger("homeassistant.components.enphase_envoy").setLevel(logging.DEBUG) + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + # Simple test to verify no sensor device class / state class mismatch warning is reported + # + # assert "which is impossible considering" not in caplog.text + assert "create a bug report at" not in caplog.text From 18d3629b6cf55cfd051b09e2fa34f719f2c2187d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 10 Jan 2026 02:43:13 +0100 Subject: [PATCH 114/163] Fix Z-Wave creating notification binary sensor for idle state (#160604) --- homeassistant/components/zwave_js/binary_sensor.py | 1 + tests/components/zwave_js/test_binary_sensor.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index b6e27b10800..f53b670ae46 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -654,6 +654,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key=NOTIFICATION_SMOKE_ALARM, entity_category=EntityCategory.DIAGNOSTIC, not_states={ + 0, SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED_LOCATION_PROVIDED, SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED, SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED, diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 5dfbb0f5bd8..b2442796466 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -383,6 +383,13 @@ async def test_smoke_co_notification_sensors( assert entity_entry assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + # Test that no idle states are created as entities + entity_id = "binary_sensor.zcombo_g_smoke_co_alarm_idle" + state = hass.states.get(entity_id) + assert state is None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is None + # Test state updates for smoke alarm event = Event( type="value updated", From de25e6af51905e69257e88333868019669190f08 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Fri, 9 Jan 2026 19:09:31 +0000 Subject: [PATCH 115/163] Bump pynintendoparental to 2.3.2 (#160626) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../components/nintendo_parental_controls/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index 29a4ceaf5a2..cd0c18e8524 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.0"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e39a0b188e7..fe953e863a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2238,7 +2238,7 @@ pynina==0.3.6 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.0 +pynintendoparental==2.3.2 # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 032fd4786b0..ac40317ce99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1888,7 +1888,7 @@ pynina==0.3.6 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.0 +pynintendoparental==2.3.2 # homeassistant.components.nobo_hub pynobo==1.8.1 From c43c4f17e9ed4b5d94a36f9e843cf8e6fefdd3df Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 12 Jan 2026 10:51:49 +0100 Subject: [PATCH 116/163] Update frontend to 20260107.1 (#160644) --- 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 17140d49ac9..ea7eb1c95f5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -23,5 +23,5 @@ "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260107.0"] + "requirements": ["home-assistant-frontend==20260107.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54489d97a9a..3d621b40453 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.8.0 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260107.0 +home-assistant-frontend==20260107.1 home-assistant-intents==2026.1.6 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe953e863a8..cb39aec778e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260107.0 +home-assistant-frontend==20260107.1 # homeassistant.components.conversation home-assistant-intents==2026.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac40317ce99..2a06b69f8ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260107.0 +home-assistant-frontend==20260107.1 # homeassistant.components.conversation home-assistant-intents==2026.1.6 From a269ef660a88f57c60752a16c2e5d113a5459c55 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Fri, 9 Jan 2026 21:04:29 -1000 Subject: [PATCH 117/163] Bump pyhik to 0.4.0 (#160654) --- homeassistant/components/hikvision/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index 5e0c201945a..31a18f7585e 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pyhik"], "quality_scale": "legacy", - "requirements": ["pyHik==0.3.4"] + "requirements": ["pyHik==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb39aec778e..67303ffdcdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1855,7 +1855,7 @@ pyElectra==1.2.4 pyEmby==1.10 # homeassistant.components.hikvision -pyHik==0.3.4 +pyHik==0.4.0 # homeassistant.components.homee pyHomee==1.3.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a06b69f8ec..14ba7fc9c15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1586,7 +1586,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.hikvision -pyHik==0.3.4 +pyHik==0.4.0 # homeassistant.components.homee pyHomee==1.3.8 From cfa110713507c2ebb3ae24165cbaa1bf041a6b47 Mon Sep 17 00:00:00 2001 From: Clifford Roche <1007595+cmroche@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:51:04 -0500 Subject: [PATCH 118/163] Bump greeclimate to 2.1.1 (#160683) --- homeassistant/components/gree/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json index dba8cd6077c..e10b9141911 100644 --- a/homeassistant/components/gree/manifest.json +++ b/homeassistant/components/gree/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/gree", "iot_class": "local_polling", "loggers": ["greeclimate"], - "requirements": ["greeclimate==2.1.0"] + "requirements": ["greeclimate==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67303ffdcdb..11522b7ef42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ gpiozero==1.6.2 gps3==0.33.3 # homeassistant.components.gree -greeclimate==2.1.0 +greeclimate==2.1.1 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14ba7fc9c15..67d12ef79eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1000,7 +1000,7 @@ govee-local-api==2.3.0 gps3==0.33.3 # homeassistant.components.gree -greeclimate==2.1.0 +greeclimate==2.1.1 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 From 2101bae095ad0ec8ebc5cc74e58dac1a37757e11 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 10 Jan 2026 13:35:46 -0600 Subject: [PATCH 119/163] Bump pysilero-vad to 3.2.0 (#160691) --- homeassistant/components/assist_pipeline/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 98804877c2e..04b6acd8885 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pysilero-vad==3.1.0", "pyspeex-noise==1.0.2"] + "requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d621b40453..deb288b97fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -56,7 +56,7 @@ PyJWT==2.10.1 PyNaCl==1.6.0 pyOpenSSL==25.3.0 pyserial==3.5 -pysilero-vad==3.1.0 +pysilero-vad==3.2.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements.txt b/requirements.txt index d7f3ca26675..26be0e93be9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pyOpenSSL==25.3.0 -pysilero-vad==3.1.0 +pysilero-vad==3.2.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 11522b7ef42..a9d78365e9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2409,7 +2409,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.1.0 +pysilero-vad==3.2.0 # homeassistant.components.sky_hub pyskyqhub==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67d12ef79eb..e577fcd6cd1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2032,7 +2032,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.assist_pipeline -pysilero-vad==3.1.0 +pysilero-vad==3.2.0 # homeassistant.components.sma pysma==1.1.0 From 013592bd54eb4523783f3c4f71258a8572b79227 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 10 Jan 2026 20:47:30 +0100 Subject: [PATCH 120/163] Revert bthome-ble back to 3.16.0 to fix missing data (#160694) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ae91ff19239..ca9744d5b63 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.17.0"] + "requirements": ["bthome-ble==3.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9d78365e9f..0297d9b0da4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -703,7 +703,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.17.0 +bthome-ble==3.16.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e577fcd6cd1..cb2703fb1c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -633,7 +633,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.17.0 +bthome-ble==3.16.0 # homeassistant.components.buienradar buienradar==1.0.6 From a42aa9372cd38e8b43f7821ef08d787134a21201 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 12 Jan 2026 11:03:36 +0100 Subject: [PATCH 121/163] Fix missing key for brew by weight in lamarzocco (#160722) --- homeassistant/components/lamarzocco/number.py | 4 ++++ homeassistant/components/lamarzocco/select.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 61db4deb6a2..2eee3956bba 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -256,6 +256,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( supported_fn=( lambda coordinator: coordinator.device.dashboard.model_name in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + and WidgetType.CM_BREW_BY_WEIGHT_DOSES + in coordinator.device.dashboard.config ), ), LaMarzoccoNumberEntityDescription( @@ -289,6 +291,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( supported_fn=( lambda coordinator: coordinator.device.dashboard.model_name in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + and WidgetType.CM_BREW_BY_WEIGHT_DOSES + in coordinator.device.dashboard.config ), ), ) diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index d7662b6f50d..921f6a8d50d 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -149,6 +149,8 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( supported_fn=( lambda coordinator: coordinator.device.dashboard.model_name in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R) + and WidgetType.CM_BREW_BY_WEIGHT_DOSES + in coordinator.device.dashboard.config ), ), ) From 1c163c92dcf6281b3c09f2764d19f43db21f803f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 11 Jan 2026 13:24:22 +0100 Subject: [PATCH 122/163] Bump pytado 0.18.16 (#160724) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 28184e459e9..ec8c53f3572 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -15,5 +15,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.15"] + "requirements": ["python-tado==0.18.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0297d9b0da4..bae778ad678 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2593,7 +2593,7 @@ python-snoo==0.8.3 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.15 +python-tado==0.18.16 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb2703fb1c2..8eb373aabbe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2177,7 +2177,7 @@ python-snoo==0.8.3 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.15 +python-tado==0.18.16 # homeassistant.components.technove python-technove==2.0.0 From ceaae1c1cc55f287c5ca2beedc772b7e17327ffd Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:59:55 +0100 Subject: [PATCH 123/163] Bump python-homewizard-energy to 10.0.1 (#160736) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 63500eb1f71..c008ec02b0a 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -13,6 +13,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==10.0.0"], + "requirements": ["python-homewizard-energy==10.0.1"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bae778ad678..c86b619a4dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2520,7 +2520,7 @@ python-google-weather-api==0.0.4 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==10.0.0 +python-homewizard-energy==10.0.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8eb373aabbe..90d3a4d9470 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2113,7 +2113,7 @@ python-google-weather-api==0.0.4 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==10.0.0 +python-homewizard-energy==10.0.1 # homeassistant.components.izone python-izone==1.2.9 From 992a9bdd3b6fd1141ba8078e88cac28bf4a579fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 12 Jan 2026 11:08:59 +0100 Subject: [PATCH 124/163] Fix fitbit icon (#160750) --- homeassistant/components/fitbit/sensor.py | 2 +- tests/components/fitbit/snapshots/test_sensor.ambr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 9cee1d4f952..d8025225df5 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -461,7 +461,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( key="sleep/timeInBed", translation_key="sleep_time_in_bed", native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:hotel", + icon="mdi:bed", device_class=SensorDeviceClass.DURATION, scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr index 068df25454d..d990996d089 100644 --- a/tests/components/fitbit/snapshots/test_sensor.ambr +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -281,7 +281,7 @@ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', 'friendly_name': 'First L. Sleep time in bed', - 'icon': 'mdi:hotel', + 'icon': 'mdi:bed', 'state_class': , 'unit_of_measurement': , }), From e238d67818999505daa52ae4aab1afa2ffd6a80a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 12 Jan 2026 11:14:27 +0100 Subject: [PATCH 125/163] Bump version to 2026.1.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 97c279aa6b0..735d3f378af 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -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 636bba559d1..c30db1be19e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.0" +version = "2026.1.1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 6824f38c68e355a724f5fa8b93862d2972df889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 12 Jan 2026 15:48:07 +0100 Subject: [PATCH 126/163] Fix Airzone Q-Adapt select entities (#160695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/entity.py | 16 ++++ homeassistant/components/airzone/select.py | 87 ++++++++++++++++++---- tests/components/airzone/test_select.py | 75 ++++++++++++++++++- 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index c0d7901981b..7513eec8a75 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -85,6 +85,22 @@ class AirzoneSystemEntity(AirzoneEntity): value = system[key] return value + async def _async_update_sys_params(self, params: dict[str, Any]) -> None: + """Send system parameters to API.""" + _params = { + API_SYSTEM_ID: self.system_id, + **params, + } + _LOGGER.debug("update_sys_params=%s", _params) + try: + await self.coordinator.airzone.set_sys_parameters(_params) + except AirzoneError as error: + raise HomeAssistantError( + f"Failed to set system {self.entity_id}: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneHotWaterEntity(AirzoneEntity): """Define an Airzone Hot Water entity.""" diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 813ead8b6a8..fe259c190ff 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -20,6 +20,7 @@ from aioairzone.const import ( AZD_MODES, AZD_Q_ADAPT, AZD_SLEEP, + AZD_SYSTEMS, AZD_ZONES, ) @@ -30,7 +31,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneZoneEntity +from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity @dataclass(frozen=True, kw_only=True) @@ -85,14 +86,7 @@ def main_zone_options( return [k for k, v in options.items() if v in modes] -MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( - AirzoneSelectDescription( - api_param=API_MODE, - key=AZD_MODE, - options_dict=MODE_DICT, - options_fn=main_zone_options, - translation_key="modes", - ), +SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( AirzoneSelectDescription( api_param=API_Q_ADAPT, entity_category=EntityCategory.CONFIG, @@ -104,6 +98,17 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( ) +MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( + AirzoneSelectDescription( + api_param=API_MODE, + key=AZD_MODE, + options_dict=MODE_DICT, + options_fn=main_zone_options, + translation_key="modes", + ), +) + + ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( AirzoneSelectDescription( api_param=API_COLD_ANGLE, @@ -140,16 +145,37 @@ async def async_setup_entry( """Add Airzone select from a config_entry.""" coordinator = entry.runtime_data + added_systems: set[str] = set() added_zones: set[str] = set() def _async_entity_listener() -> None: """Handle additions of select.""" + entities: list[AirzoneBaseSelect] = [] + + systems_data = coordinator.data.get(AZD_SYSTEMS, {}) + received_systems = set(systems_data) + new_systems = received_systems - added_systems + if new_systems: + entities.extend( + AirzoneSystemSelect( + coordinator, + description, + entry, + system_id, + systems_data.get(system_id), + ) + for system_id in new_systems + for description in SYSTEM_SELECT_TYPES + if description.key in systems_data.get(system_id) + ) + added_systems.update(new_systems) + zones_data = coordinator.data.get(AZD_ZONES, {}) received_zones = set(zones_data) new_zones = received_zones - added_zones if new_zones: - entities: list[AirzoneZoneSelect] = [ + entities.extend( AirzoneZoneSelect( coordinator, description, @@ -161,8 +187,8 @@ async def async_setup_entry( for description in MAIN_ZONE_SELECT_TYPES if description.key in zones_data.get(system_zone_id) and zones_data.get(system_zone_id).get(AZD_MASTER) is True - ] - entities += [ + ) + entities.extend( AirzoneZoneSelect( coordinator, description, @@ -173,10 +199,11 @@ async def async_setup_entry( for system_zone_id in new_zones for description in ZONE_SELECT_TYPES if description.key in zones_data.get(system_zone_id) - ] - async_add_entities(entities) + ) added_zones.update(new_zones) + async_add_entities(entities) + entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener)) _async_entity_listener() @@ -203,6 +230,38 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity): self._attr_current_option = self._get_current_option() +class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect): + """Define an Airzone System select.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneSelectDescription, + entry: ConfigEntry, + system_id: str, + system_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, system_data) + + self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}" + self.entity_description = description + + self._attr_options = self.entity_description.options_fn( + system_data, description.options_dict + ) + + self.values_dict = {v: k for k, v in description.options_dict.items()} + + self._async_update_attrs() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + param = self.entity_description.api_param + value = self.entity_description.options_dict[option] + await self._async_update_sys_params({param: value}) + + class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): """Define an Airzone Zone select.""" diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index 343c033728a..4421822f1c8 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -2,12 +2,13 @@ from unittest.mock import patch -from aioairzone.common import OperationMode +from aioairzone.common import OperationMode, QAdapt from aioairzone.const import ( API_COLD_ANGLE, API_DATA, API_HEAT_ANGLE, API_MODE, + API_Q_ADAPT, API_SLEEP, API_SYSTEM_ID, API_ZONE_ID, @@ -17,7 +18,7 @@ import pytest from homeassistant.components.select import ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .util import async_init_integration @@ -27,6 +28,11 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Systems + state = hass.states.get("select.system_1_q_adapt") + assert state.state == "standard" + + # Zones state = hass.states.get("select.despacho_cold_angle") assert state.state == "90deg" @@ -95,6 +101,71 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None: assert state.state == "off" +async def test_airzone_select_sys_qadapt(hass: HomeAssistant) -> None: + """Test select system Q-Adapt.""" + + await async_init_integration(hass) + + put_q_adapt = { + API_DATA: { + API_SYSTEM_ID: 1, + API_Q_ADAPT: QAdapt.SILENCE, + } + } + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.system_1_q_adapt", + ATTR_OPTION: "Invalid", + }, + blocking=True, + ) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=put_q_adapt, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.system_1_q_adapt", + ATTR_OPTION: "silence", + }, + blocking=True, + ) + + state = hass.states.get("select.system_1_q_adapt") + assert state.state == "silence" + + put_q_adapt = { + API_DATA: { + API_SYSTEM_ID: 2, + API_Q_ADAPT: QAdapt.SILENCE, + } + } + + with ( + patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=put_q_adapt, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.system_1_q_adapt", + ATTR_OPTION: "silence", + }, + blocking=True, + ) + + async def test_airzone_select_sleep(hass: HomeAssistant) -> None: """Test select sleep.""" From 4ee3ac16afd11c1a7146a940417e422215d6007f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 13 Jan 2026 08:09:17 -0600 Subject: [PATCH 127/163] Revert back to microVAD (#160821) --- .../assist_pipeline/audio_enhancer.py | 49 ++++--------------- .../components/assist_pipeline/manifest.json | 2 +- .../components/assist_pipeline/pipeline.py | 4 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 6 +-- requirements_test_all.txt | 6 +-- 7 files changed, 21 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/assist_pipeline/audio_enhancer.py b/homeassistant/components/assist_pipeline/audio_enhancer.py index 18f00d58d8a..1fabc7790e7 100644 --- a/homeassistant/components/assist_pipeline/audio_enhancer.py +++ b/homeassistant/components/assist_pipeline/audio_enhancer.py @@ -3,9 +3,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass import logging -import math -from pysilero_vad import SileroVoiceActivityDetector +from pymicro_vad import MicroVad from pyspeex_noise import AudioProcessor from .const import BYTES_PER_CHUNK @@ -43,8 +42,8 @@ class AudioEnhancer(ABC): """Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples.""" -class SileroVadSpeexEnhancer(AudioEnhancer): - """Audio enhancer that runs Silero VAD and speex.""" +class MicroVadSpeexEnhancer(AudioEnhancer): + """Audio enhancer that runs microVAD and speex.""" def __init__( self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool @@ -70,49 +69,21 @@ class SileroVadSpeexEnhancer(AudioEnhancer): self.noise_suppression, ) - self.vad: SileroVoiceActivityDetector | None = None - - # We get 10ms chunks but Silero works on 32ms chunks, so we have to - # buffer audio. The previous speech probability is used until enough - # audio has been buffered. - self._vad_buffer: bytearray | None = None - self._vad_buffer_chunks = 0 - self._vad_buffer_chunk_idx = 0 - self._last_speech_probability: float | None = None + self.vad: MicroVad | None = None if self.is_vad_enabled: - self.vad = SileroVoiceActivityDetector() - - # VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms. - self._vad_buffer_chunks = int( - math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK) - ) - self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK - self._vad_buffer = bytearray(self.vad.chunk_bytes()) - _LOGGER.debug("Initialized Silero VAD") + self.vad = MicroVad() + _LOGGER.debug("Initialized microVAD") def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk: """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples.""" + speech_probability: float | None = None + assert len(audio) == BYTES_PER_CHUNK if self.vad is not None: # Run VAD - assert self._vad_buffer is not None - start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK - self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio - - self._vad_buffer_chunk_idx += 1 - if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks: - # We have enough data to run Silero VAD (32 ms) - self._last_speech_probability = self.vad.process_chunk( - self._vad_buffer[: self.vad.chunk_bytes()] - ) - - # Copy leftover audio that wasn't processed to start - self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[ - -self._vad_leftover_bytes : - ] - self._vad_buffer_chunk_idx = 0 + speech_probability = self.vad.Process10ms(audio) if self.audio_processor is not None: # Run noise suppression and auto gain @@ -121,5 +92,5 @@ class SileroVadSpeexEnhancer(AudioEnhancer): return EnhancedAudioChunk( audio=audio, timestamp_ms=timestamp_ms, - speech_probability=self._last_speech_probability, + speech_probability=speech_probability, ) diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 04b6acd8885..d88e4352130 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"] + "requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index abfc4e72782..0948413d4cc 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -55,7 +55,7 @@ from homeassistant.util import ( from homeassistant.util.hass_dict import HassKey from homeassistant.util.limited_size_dict import LimitedSizeDict -from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer +from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer from .const import ( ACKNOWLEDGE_PATH, BYTES_PER_CHUNK, @@ -633,7 +633,7 @@ class PipelineRun: # Initialize with audio settings if self.audio_settings.needs_processor and (self.audio_enhancer is None): # Default audio enhancer - self.audio_enhancer = SileroVadSpeexEnhancer( + self.audio_enhancer = MicroVadSpeexEnhancer( self.audio_settings.auto_gain_dbfs, self.audio_settings.noise_suppression_level, self.audio_settings.is_vad_enabled, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index deb288b97fe..09a021b9abe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,10 +53,10 @@ Pillow==12.0.0 propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 +pymicro-vad==1.0.1 PyNaCl==1.6.0 pyOpenSSL==25.3.0 pyserial==3.5 -pysilero-vad==3.2.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements.txt b/requirements.txt index 26be0e93be9..f413b5a44fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,8 +39,8 @@ Pillow==12.0.0 propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 +pymicro-vad==1.0.1 pyOpenSSL==25.3.0 -pysilero-vad==3.2.0 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index c86b619a4dd..59a89ed0b8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2201,6 +2201,9 @@ pymediaroom==0.6.5.4 # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 +# homeassistant.components.assist_pipeline +pymicro-vad==1.0.1 + # homeassistant.components.miele pymiele==0.6.1 @@ -2408,9 +2411,6 @@ pysiaalarm==3.1.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.24 -# homeassistant.components.assist_pipeline -pysilero-vad==3.2.0 - # homeassistant.components.sky_hub pyskyqhub==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90d3a4d9470..9e8c8b81bb4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,6 +1860,9 @@ pymata-express==1.19 # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 +# homeassistant.components.assist_pipeline +pymicro-vad==1.0.1 + # homeassistant.components.miele pymiele==0.6.1 @@ -2031,9 +2034,6 @@ pysiaalarm==3.1.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.24 -# homeassistant.components.assist_pipeline -pysilero-vad==3.2.0 - # homeassistant.components.sma pysma==1.1.0 From 0643b36ed5fe3b973c2caa2f5735ed42b8ef08c2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 12 Jan 2026 19:20:06 -0800 Subject: [PATCH 128/163] Bump opower to 0.16.2 (#160822) --- 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 036e4c2b973..0e23c3c9c3d 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.16.1"] + "requirements": ["opower==0.16.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59a89ed0b8e..2185cd5f5db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.16.1 +opower==0.16.2 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e8c8b81bb4..0e11a5e9fa1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.16.1 +opower==0.16.2 # homeassistant.components.oralb oralb-ble==1.0.2 From 447da083c071161d387021903566cdbda3f951ff Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 13 Jan 2026 10:18:15 +0100 Subject: [PATCH 129/163] accept leading zeros in sms_code for fressnapf_tracker (#160834) --- .../fressnapf_tracker/config_flow.py | 4 ++-- .../components/fressnapf_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fressnapf_tracker/test_config_flow.py | 18 +++++++++--------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fressnapf_tracker/config_flow.py b/homeassistant/components/fressnapf_tracker/config_flow.py index 3823246308e..50531fe4624 100644 --- a/homeassistant/components/fressnapf_tracker/config_flow.py +++ b/homeassistant/components/fressnapf_tracker/config_flow.py @@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) STEP_SMS_CODE_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_SMS_CODE): int, + vol.Required(CONF_SMS_CODE): str, } ) @@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN): return errors, False async def _async_verify_sms_code( - self, sms_code: int + self, sms_code: str ) -> tuple[dict[str, str], str | None]: """Verify SMS code and return errors and access_token.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/fressnapf_tracker/manifest.json b/homeassistant/components/fressnapf_tracker/manifest.json index 4e493db07e9..482db2aedb0 100644 --- a/homeassistant/components/fressnapf_tracker/manifest.json +++ b/homeassistant/components/fressnapf_tracker/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["fressnapftracker==0.2.0"] + "requirements": ["fressnapftracker==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2185cd5f5db..2ef39386176 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1011,7 +1011,7 @@ freebox-api==1.2.2 freesms==0.2.0 # homeassistant.components.fressnapf_tracker -fressnapftracker==0.2.0 +fressnapftracker==0.2.1 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e11a5e9fa1..87e8577183d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ forecast-solar==4.2.0 freebox-api==1.2.2 # homeassistant.components.fressnapf_tracker -fressnapftracker==0.2.0 +fressnapftracker==0.2.1 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor diff --git a/tests/components/fressnapf_tracker/test_config_flow.py b/tests/components/fressnapf_tracker/test_config_flow.py index 295476ba6e9..d789a889f38 100644 --- a/tests/components/fressnapf_tracker/test_config_flow.py +++ b/tests/components/fressnapf_tracker/test_config_flow.py @@ -50,7 +50,7 @@ async def test_user_flow_success( # Submit SMS code result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -107,7 +107,7 @@ async def test_user_flow_request_sms_code_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -142,7 +142,7 @@ async def test_user_flow_verify_phone_number_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 999999}, + {CONF_SMS_CODE: "999999"}, ) assert result["type"] is FlowResultType.FORM @@ -153,7 +153,7 @@ async def test_user_flow_verify_phone_number_errors( mock_auth_client.verify_phone_number.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -246,7 +246,7 @@ async def test_reauth_reconfigure_flow( # Submit SMS code result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.ABORT @@ -311,7 +311,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.ABORT @@ -358,7 +358,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 999999}, + {CONF_SMS_CODE: "999999"}, ) assert result["type"] is FlowResultType.FORM @@ -369,7 +369,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code( mock_auth_client.verify_phone_number.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.ABORT @@ -436,7 +436,7 @@ async def test_reauth_reconfigure_flow_invalid_user_id( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_SMS_CODE: 123456}, + {CONF_SMS_CODE: "0123456"}, ) assert result["type"] is FlowResultType.ABORT From 79fd98753acf90859cb506946e0f83c49e5b9947 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 13 Jan 2026 18:14:46 +0100 Subject: [PATCH 130/163] Add support for packaging version >= 26 on the version bump script (#160858) --- script/version_bump.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/script/version_bump.py b/script/version_bump.py index 2a7d82937f1..91571bad169 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -2,15 +2,19 @@ """Helper script to bump the current version.""" import argparse +from copy import replace from pathlib import Path import re import subprocess +import packaging from packaging.version import Version from homeassistant import const from homeassistant.util import dt as dt_util +_PACKAGING_VERSION_BELOW_26 = Version(packaging.__version__) < Version("26.0dev0") + def _bump_release(release, bump_type): """Bump a release tuple consisting of 3 numbers.""" @@ -25,6 +29,13 @@ def _bump_release(release, bump_type): return major, minor, patch +def _get_dev_change(dev: int) -> int | tuple[str, int]: + """Return the dev change based on packaging version.""" + if _PACKAGING_VERSION_BELOW_26: + return ("dev", dev) + return dev + + def bump_version( version: Version, bump_type: str, *, nightly_version: str | None = None ) -> Version: @@ -58,9 +69,10 @@ def bump_version( # Convert 0.67.3.b5 to 0.67.4.dev0 # Convert 0.67.3.dev0 to 0.67.3.dev1 if version.is_devrelease: - to_change["dev"] = ("dev", version.dev + 1) + to_change["dev"] = _get_dev_change(version.dev + 1) else: - to_change["pre"] = ("dev", 0) + to_change["dev"] = _get_dev_change(0) + to_change["pre"] = None to_change["release"] = _bump_release(version.release, "minor") elif bump_type == "beta": @@ -99,14 +111,19 @@ def bump_version( raise ValueError("Nightly version must be a dev version") new_dev = new_version.dev - to_change["dev"] = ("dev", new_dev) + if not isinstance(new_dev, int): + new_dev = int(new_dev) + to_change["dev"] = _get_dev_change(new_dev) else: raise ValueError(f"Unsupported type: {bump_type}") - temp = Version("0") - temp._version = version._version._replace(**to_change) # noqa: SLF001 - return Version(str(temp)) + if _PACKAGING_VERSION_BELOW_26: + temp = Version("0") + temp._version = version._version._replace(**to_change) # noqa: SLF001 + return Version(str(temp)) + + return replace(version, **to_change) def write_version(version): From b202c8b43ecdafa83267bfbe7d82639485cecc9a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:21:09 +0100 Subject: [PATCH 131/163] Update PyNaCl to 1.6.2 (#160909) --- homeassistant/components/mobile_app/manifest.json | 2 +- homeassistant/components/owntracks/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 6e4651ab0db..e1e394be363 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["nacl"], "quality_scale": "internal", - "requirements": ["PyNaCl==1.6.0"] + "requirements": ["PyNaCl==1.6.2"] } diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json index 81da21b2a54..ade83e87473 100644 --- a/homeassistant/components/owntracks/manifest.json +++ b/homeassistant/components/owntracks/manifest.json @@ -9,6 +9,6 @@ "integration_type": "service", "iot_class": "local_push", "loggers": ["nacl"], - "requirements": ["PyNaCl==1.6.0"], + "requirements": ["PyNaCl==1.6.2"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 09a021b9abe..06c0d94377b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -54,7 +54,7 @@ propcache==0.4.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 -PyNaCl==1.6.0 +PyNaCl==1.6.2 pyOpenSSL==25.3.0 pyserial==3.5 pyspeex-noise==1.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2ef39386176..af86595b3e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.6.0 +PyNaCl==1.6.2 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87e8577183d..76a6e4d253d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.6.0 +PyNaCl==1.6.2 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit From 01ea5a16349c31f7ba508658d7c6c5f861c69b04 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:57:05 +0100 Subject: [PATCH 132/163] Bump pyenphase from 2.4.2 to 2.4.3 (#160912) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index bebbbe004e9..abc8d4cdad2 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.4.2"], + "requirements": ["pyenphase==2.4.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index af86595b3e3..5aef0bd371d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2022,7 +2022,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==2.4.2 +pyenphase==2.4.3 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76a6e4d253d..7c960a24ae8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1714,7 +1714,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==2.4.2 +pyenphase==2.4.3 # homeassistant.components.everlights pyeverlights==0.1.0 From 572092d362cb98305f8a6a139a7dacd94d078880 Mon Sep 17 00:00:00 2001 From: Jaap Pieroen Date: Thu, 15 Jan 2026 19:42:18 +0100 Subject: [PATCH 133/163] Decrease Essent update interval to 1 hour (#160959) --- homeassistant/components/essent/const.py | 2 +- tests/components/essent/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/essent/const.py b/homeassistant/components/essent/const.py index 6b4167925c0..4b505e21136 100644 --- a/homeassistant/components/essent/const.py +++ b/homeassistant/components/essent/const.py @@ -7,7 +7,7 @@ from enum import StrEnum from typing import Final DOMAIN: Final = "essent" -UPDATE_INTERVAL: Final = timedelta(hours=12) +UPDATE_INTERVAL: Final = timedelta(hours=1) ATTRIBUTION: Final = "Data provided by Essent" diff --git a/tests/components/essent/test_sensor.py b/tests/components/essent/test_sensor.py index ce20518b527..db3c4c4cce2 100644 --- a/tests/components/essent/test_sensor.py +++ b/tests/components/essent/test_sensor.py @@ -74,5 +74,5 @@ async def test_sensor_updates_on_hour_tick( assert ( hass.states.get("sensor.essent_current_electricity_market_price").state - == "0.10417" + == "0.24535" ) From 6474a1bf6356d8579d1492463d32549db92f14ae Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 14 Jan 2026 23:29:01 -0800 Subject: [PATCH 134/163] Bump opower to 0.16.3 (#160961) --- 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 0e23c3c9c3d..19a692fa643 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.16.2"] + "requirements": ["opower==0.16.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5aef0bd371d..9fea6db80d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.16.2 +opower==0.16.3 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c960a24ae8..7fd346b8b02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.16.2 +opower==0.16.3 # homeassistant.components.oralb oralb-ble==1.0.2 From 68cbdcf3c9cae2c9cf919c4ed6455807528db6e0 Mon Sep 17 00:00:00 2001 From: Niracler Date: Thu, 15 Jan 2026 16:44:39 +0800 Subject: [PATCH 135/163] Bump PySrDaliGateway from 0.18.0 to 0.19.3 (#160972) Co-authored-by: Josef Zweck --- homeassistant/components/sunricher_dali/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index 214c822fc01..80524a9bfb1 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunricher_dali", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["PySrDaliGateway==0.18.0"] + "requirements": ["PySrDaliGateway==0.19.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fea6db80d3..edf3e759a67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.18.0 +PySrDaliGateway==0.19.3 # homeassistant.components.switchbot PySwitchbot==0.75.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fd346b8b02..64a5ffc57dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.18.0 +PySrDaliGateway==0.19.3 # homeassistant.components.switchbot PySwitchbot==0.75.0 From 9ac5560c41c3df2c88fae53eee1cbbe244e46e36 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 15 Jan 2026 10:40:52 +0100 Subject: [PATCH 136/163] Add descriptions to openai_conversation (#160979) Co-authored-by: Norbert Rittel --- .../openai_conversation/config_flow.py | 72 ++++++++++--------- .../openai_conversation/strings.json | 6 +- .../openai_conversation/test_config_flow.py | 2 +- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index cdfd3b72cfc..426ee7d1945 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -112,45 +112,49 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) errors: dict[str, str] = {} - self._async_abort_entries_match(user_input) - try: - await validate_input(self.hass, user_input) - except openai.APIConnectionError: - errors["base"] = "cannot_connect" - except openai.AuthenticationError: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry( - title="ChatGPT", - data=user_input, - subentries=[ - { - "subentry_type": "conversation", - "data": RECOMMENDED_CONVERSATION_OPTIONS, - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - }, - { - "subentry_type": "ai_task_data", - "data": RECOMMENDED_AI_TASK_OPTIONS, - "title": DEFAULT_AI_TASK_NAME, - "unique_id": None, - }, - ], - ) + if user_input is not None: + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input) + except openai.APIConnectionError: + errors["base"] = "cannot_connect" + except openai.AuthenticationError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="ChatGPT", + data=user_input, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + ], + ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={ + "instructions_url": "https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key", + }, ) @classmethod diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 4b870d23c30..a5f283f8712 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -12,7 +12,11 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" - } + }, + "data_description": { + "api_key": "Your OpenAI API key." + }, + "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})." } } }, diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 202514a77b5..20fae149c2a 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -58,7 +58,7 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with ( patch( From c2e1646473435deb16f2310983f2fa5880b37df4 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 16 Jan 2026 12:51:42 +0100 Subject: [PATCH 137/163] Clean up unnecessary Z-Wave "device config changed" repairs (#161000) --- homeassistant/components/zwave_js/__init__.py | 33 +++++---- tests/components/zwave_js/test_repairs.py | 70 +++++++++++++++---- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 2076c37856e..e14fd0757f6 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -840,19 +840,26 @@ class NodeEvents: # After ensuring the node is set up in HA, we should check if the node's # device config has changed, and if so, issue a repair registry entry for a # possible reinterview - if not node.is_controller_node and await node.async_has_device_config_changed(): - device_name = device.name_by_user or device.name or "Unnamed device" - async_create_issue( - self.hass, - DOMAIN, - f"device_config_file_changed.{device.id}", - data={"device_id": device.id, "device_name": device_name}, - is_fixable=True, - is_persistent=False, - translation_key="device_config_file_changed", - translation_placeholders={"device_name": device_name}, - severity=IssueSeverity.WARNING, - ) + if not node.is_controller_node: + issue_id = f"device_config_file_changed.{device.id}" + if await node.async_has_device_config_changed(): + device_name = device.name_by_user or device.name or "Unnamed device" + async_create_issue( + self.hass, + DOMAIN, + issue_id, + data={"device_id": device.id, "device_name": device_name}, + is_fixable=True, + is_persistent=False, + translation_key="device_config_file_changed", + translation_placeholders={"device_name": device_name}, + severity=IssueSeverity.WARNING, + ) + else: + # Clear any existing repair issue if the device config is not considered + # changed. This can happen when the original issue was created by + # an upstream bug, or the change has been reverted. + async_delete_issue(self.hass, DOMAIN, issue_id) async def async_handle_discovery_info( self, diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d47fd771127..cb2c5a846c7 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -4,8 +4,9 @@ from copy import deepcopy from unittest.mock import MagicMock, patch import pytest +from zwave_js_server.client import Client from zwave_js_server.event import Event -from zwave_js_server.model.node import Node +from zwave_js_server.model.node import Node, NodeDataType from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES @@ -23,9 +24,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator async def _trigger_repair_issue( - hass: HomeAssistant, client, multisensor_6_state + hass: HomeAssistant, + client: Client, + multisensor_6_state: NodeDataType, + device_config_changed: bool = True, ) -> Node: - """Trigger repair issue.""" + """Trigger repair issue with configurable device config changed status.""" # Create a node node_state = deepcopy(multisensor_6_state) node = Node(client, node_state) @@ -40,7 +44,7 @@ async def _trigger_repair_issue( ) with patch( "zwave_js_server.model.node.Node.async_has_device_config_changed", - return_value=True, + return_value=device_config_changed, ): client.driver.controller.receive_event(event) await hass.async_block_till_done() @@ -55,9 +59,9 @@ async def test_device_config_file_changed_confirm_step( hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test the device_config_file_changed issue confirm step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -116,14 +120,54 @@ async def test_device_config_file_changed_confirm_step( assert len(msg["result"]["issues"]) == 0 +async def test_device_config_file_changed_cleared( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, +) -> None: + """Test the device_config_file_changed issue is cleared when no longer true.""" + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + + # Simulate the node becoming ready again with device config no longer changed + await _trigger_repair_issue( + hass, client, multisensor_6_state, device_config_changed=False + ) + + # Assert the issue is now cleared + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + async def test_device_config_file_changed_ignore_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test the device_config_file_changed issue ignore step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -237,9 +281,9 @@ async def test_abort_confirm( hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test aborting device_config_file_changed issue in confirm step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) From 691cf67f682b4db360f04674f2edba39fcf9773e Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 16 Jan 2026 09:37:09 +0100 Subject: [PATCH 138/163] Update knx-frontend to 2026.1.15.112308 (#161004) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 5f5862d9219..8a0d4583e3d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.13.0", "xknxproject==3.8.2", - "knx-frontend==2025.12.30.151231" + "knx-frontend==2026.1.15.112308" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index edf3e759a67..fe682abaddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1349,7 +1349,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.30.151231 +knx-frontend==2026.1.15.112308 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64a5ffc57dd..849732d8333 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ kegtron-ble==1.0.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.30.151231 +knx-frontend==2026.1.15.112308 # homeassistant.components.konnected konnected==1.2.0 From 04809e47f1f17372c1c918ea320b2fab3e822ea6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 15 Jan 2026 16:46:55 +0100 Subject: [PATCH 139/163] Require admin for blueprint ws commands (#161008) --- .../components/blueprint/websocket_api.py | 5 ++ .../blueprint/test_websocket_api.py | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 0743d027d8d..873e3b30a36 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -64,6 +64,7 @@ def _ws_with_blueprint_domain( return with_domain_blueprints +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/list", @@ -97,6 +98,7 @@ async def ws_list_blueprints( connection.send_result(msg["id"], results) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/import", @@ -150,6 +152,7 @@ async def ws_import_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/save", @@ -206,6 +209,7 @@ async def ws_save_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/delete", @@ -233,6 +237,7 @@ async def ws_delete_blueprint( ) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "blueprint/substitute", diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 8374054ca95..96a9323fda5 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml +from tests.common import MockUser from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -103,6 +104,51 @@ async def test_list_blueprints_non_existing_domain( assert blueprints == {} +@pytest.mark.parametrize( + "message", + [ + {"type": "blueprint/list", "domain": "automation"}, + {"type": "blueprint/import", "url": "https://example.com/blueprint.yaml"}, + { + "type": "blueprint/save", + "path": "test_save", + "yaml": "raw_data", + "domain": "automation", + }, + { + "type": "blueprint/delete", + "path": "test_delete", + "domain": "automation", + }, + { + "type": "blueprint/substitute", + "domain": "automation", + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + }, + ], +) +async def test_blueprint_ws_command_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + message: dict[str, Any], +) -> None: + """Test that blueprint websocket commands require admin.""" + hass_admin_user.groups = [] # Remove admin privileges + client = await hass_ws_client(hass) + await client.send_json_auto_id(message) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + async def test_import_blueprint( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 16f4849e882529de5bbaed03222f5566a16bb14a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 16 Jan 2026 12:37:22 +0000 Subject: [PATCH 140/163] Bump aiomealie to 1.2.0 (#161058) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mealie/snapshots/test_services.ambr | 110 ++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 5e090a6af73..21fe1b11197 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.1.1"] + "requirements": ["aiomealie==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe682abaddf..0e5c2013e22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -319,7 +319,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.1 +aiomealie==1.2.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 849732d8333..34afc3051fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -304,7 +304,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.1 +aiomealie==1.2.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 30f70bc9273..2591741c8d2 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1647,80 +1647,135 @@ 'image': 'SuPW', 'ingredients': list([ dict({ + 'display': '1 130g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 1 Vanilla Pod', + 'food': None, 'is_food': True, 'note': '1 Vanilla Pod', + 'original_text': None, 'quantity': 1.0, 'reference_id': '41d234d7-c040-48f9-91e6-f4636aebb77b', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g softened butter', + 'food': None, 'is_food': None, 'note': '150g softened butter', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Icing sugar', + 'food': None, 'is_food': True, 'note': '100g Icing sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f7fcd86e-b04b-4e07-b69c-513925811491', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 6 Eggs', + 'food': None, 'is_food': True, 'note': '6 Eggs', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a831fbc3-e2f5-452e-a745-450be8b4a130', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Castor sugar', + 'food': None, 'is_food': True, 'note': '100g Castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'b5ee4bdc-0047-4de7-968b-f3360bbcb31e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 140g Plain wheat flour', + 'food': None, 'is_food': True, 'note': '140g Plain wheat flour', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a67db09d-429c-4e77-919d-cfed3da675ad', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g apricot jam', + 'food': None, 'is_food': True, 'note': '200g apricot jam', + 'original_text': None, 'quantity': 1.0, 'reference_id': '55479752-c062-4b25-aae3-2b210999d7b9', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g castor sugar', + 'food': None, 'is_food': True, 'note': '200g castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ff9cd404-24ec-4d38-b0aa-0120ce1df679', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': True, 'note': '150g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'c7fca92e-971e-4728-a227-8b04783583ed', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 Unsweetend whipped cream to garnish', + 'food': None, 'is_food': True, 'note': 'Unsweetend whipped cream to garnish', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ef023f23-7816-4871-87f6-4d29f9a283f7', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), ]), @@ -2223,80 +2278,135 @@ 'image': 'SuPW', 'ingredients': list([ dict({ + 'display': '1 130g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 1 Vanilla Pod', + 'food': None, 'is_food': True, 'note': '1 Vanilla Pod', + 'original_text': None, 'quantity': 1.0, 'reference_id': '41d234d7-c040-48f9-91e6-f4636aebb77b', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g softened butter', + 'food': None, 'is_food': None, 'note': '150g softened butter', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Icing sugar', + 'food': None, 'is_food': True, 'note': '100g Icing sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f7fcd86e-b04b-4e07-b69c-513925811491', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 6 Eggs', + 'food': None, 'is_food': True, 'note': '6 Eggs', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a831fbc3-e2f5-452e-a745-450be8b4a130', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Castor sugar', + 'food': None, 'is_food': True, 'note': '100g Castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'b5ee4bdc-0047-4de7-968b-f3360bbcb31e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 140g Plain wheat flour', + 'food': None, 'is_food': True, 'note': '140g Plain wheat flour', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a67db09d-429c-4e77-919d-cfed3da675ad', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g apricot jam', + 'food': None, 'is_food': True, 'note': '200g apricot jam', + 'original_text': None, 'quantity': 1.0, 'reference_id': '55479752-c062-4b25-aae3-2b210999d7b9', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g castor sugar', + 'food': None, 'is_food': True, 'note': '200g castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ff9cd404-24ec-4d38-b0aa-0120ce1df679', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': True, 'note': '150g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'c7fca92e-971e-4728-a227-8b04783583ed', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 Unsweetend whipped cream to garnish', + 'food': None, 'is_food': True, 'note': 'Unsweetend whipped cream to garnish', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ef023f23-7816-4871-87f6-4d29f9a283f7', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), ]), From 6dd8692bb8755187ca8d7007a557babc1914d62a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 16 Jan 2026 16:44:08 +0100 Subject: [PATCH 141/163] Update frontend to 20260107.2 (#161061) --- 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 ea7eb1c95f5..cbbf414fbb3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -23,5 +23,5 @@ "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260107.1"] + "requirements": ["home-assistant-frontend==20260107.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06c0d94377b..a0c1f09749d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.8.0 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260107.1 +home-assistant-frontend==20260107.2 home-assistant-intents==2026.1.6 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0e5c2013e22..94b9a0f0099 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260107.1 +home-assistant-frontend==20260107.2 # homeassistant.components.conversation home-assistant-intents==2026.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34afc3051fb..43ae2c8cca5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260107.1 +home-assistant-frontend==20260107.2 # homeassistant.components.conversation home-assistant-intents==2026.1.6 From 5f6dce55036e4840788d00fd5eac1c87dc51fcc9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 16 Jan 2026 19:52:58 +0000 Subject: [PATCH 142/163] Bump version to 2026.1.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 735d3f378af..15ec98c953d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -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 c30db1be19e..142174002bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.1" +version = "2026.1.2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From c5b72ac286559407a4cde5a538ef23d3800efa4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 10 Jan 2026 20:43:10 +0100 Subject: [PATCH 143/163] Update aioairzone to v1.0.5 (#160688) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index d5aeeb7988d..b6f87438f3b 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==1.0.4"] + "requirements": ["aioairzone==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 94b9a0f0099..2818f4177ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -187,7 +187,7 @@ aioairq==0.4.7 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.4 +aioairzone==1.0.5 # homeassistant.components.alexa_devices aioamazondevices==11.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43ae2c8cca5..06474c33e80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,7 +178,7 @@ aioairq==0.4.7 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.4 +aioairzone==1.0.5 # homeassistant.components.alexa_devices aioamazondevices==11.0.2 From e6772307bad4e9d9798351bcbe99eacd911159e4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:06:50 +0100 Subject: [PATCH 144/163] Bump uiprotect to 8.1.1 (#160816) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5ba6b39bcd9..4753419fd0c 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 2818f4177ca..061aacc8a30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==8.0.0 +uiprotect==8.1.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06474c33e80..5dfc7a0c11b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2569,7 +2569,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==8.0.0 +uiprotect==8.1.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 368a411f012c75a44ebf1c1c834fda042b245ce1 Mon Sep 17 00:00:00 2001 From: Allan Lewis Date: Sat, 17 Jan 2026 14:44:34 +0000 Subject: [PATCH 145/163] Update list of supported locations for London Air (#160884) --- homeassistant/components/london_air/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index a4d34fcb2d6..b3c7535b9b7 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -27,6 +27,7 @@ SCAN_INTERVAL = timedelta(minutes=30) AUTHORITIES = [ "Barking and Dagenham", + "Barnet", "Bexley", "Brent", "Bromley", @@ -49,11 +50,13 @@ AUTHORITIES = [ "Lambeth", "Lewisham", "Merton", + "Newham", "Redbridge", "Richmond", "Southwark", "Sutton", "Tower Hamlets", + "Waltham Forest", "Wandsworth", "Westminster", ] From fcb4ed6cef960e29ac0973875aa279224c57e26c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 15 Jan 2026 10:39:22 +0100 Subject: [PATCH 146/163] Bump onedrive-personal-sdk to 0.1.0 (#160976) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index df861f99751..982fceb1e04 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.17"] + "requirements": ["onedrive-personal-sdk==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 061aacc8a30..6fe3a1b2217 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1644,7 +1644,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.17 +onedrive-personal-sdk==0.1.0 # homeassistant.components.onvif onvif-zeep-async==4.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5dfc7a0c11b..4ddaa060b3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1427,7 +1427,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.17 +onedrive-personal-sdk==0.1.0 # homeassistant.components.onvif onvif-zeep-async==4.0.4 From 9016c5d51d419db638abfa53d2dd004608da0b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 17 Jan 2026 11:55:44 +0100 Subject: [PATCH 147/163] Adjust battery voltage sensor display precision for Matter devices (#161088) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/matter/sensor.py | 3 ++ .../matter/snapshots/test_sensor.ambr | 28 +++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0832675744e..3077ff10cc6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -442,6 +442,9 @@ DISCOVERY_SCHEMAS = [ key="PowerSourceBatVoltage", translation_key="battery_voltage", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + # Battery voltages are low-voltage diagnostics; use 2 decimals in volts + # to provide finer granularity than mains-level voltage sensors. + suggested_display_precision=2, suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index f9a2a1fdf08..da5ecae2326 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1375,7 +1375,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -1535,7 +1535,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -1748,7 +1748,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -2070,7 +2070,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -2612,7 +2612,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -2847,7 +2847,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -4137,7 +4137,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -4982,7 +4982,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -5199,7 +5199,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -5847,7 +5847,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -6313,7 +6313,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -6919,7 +6919,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -11404,7 +11404,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -12661,7 +12661,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , From 4ac42ea39fcf6de384c62384b7d05f06649f7253 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:35:36 +0100 Subject: [PATCH 148/163] Fix color temperature attributes in wiz (#161125) --- homeassistant/components/wiz/light.py | 7 +-- tests/components/wiz/__init__.py | 23 ++++++- tests/components/wiz/snapshots/test_fan.ambr | 65 +++++++++++++++++++- tests/components/wiz/test_fan.py | 18 +++++- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 509d40d654d..8a6de65cf73 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -80,6 +80,9 @@ class WizBulbEntity(WizToggleEntity, LightEntity): color_modes.add(RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels]) if features.color_tmp: color_modes.add(ColorMode.COLOR_TEMP) + kelvin = bulb_type.kelvin_range + self._attr_max_color_temp_kelvin = kelvin.max + self._attr_min_color_temp_kelvin = kelvin.min if features.brightness: color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(color_modes) @@ -87,10 +90,6 @@ class WizBulbEntity(WizToggleEntity, LightEntity): # If the light supports only a single color mode, set it now self._attr_color_mode = next(iter(self._attr_supported_color_modes)) self._attr_effect_list = wiz_data.scenes - if bulb_type.bulb_type != BulbClass.DW: - kelvin = bulb_type.kelvin_range - self._attr_max_color_temp_kelvin = kelvin.max - self._attr_min_color_temp_kelvin = kelvin.min if bulb_type.features.effect: self._attr_supported_features = LightEntityFeature.EFFECT self._async_update_attrs() diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index 037b6a1dfbd..a11a7251998 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -183,7 +183,7 @@ FAKE_DIMMABLE_FAN = BulbType( features=Features( color=False, color_tmp=False, - effect=True, + effect=False, brightness=True, dual_head=False, fan=True, @@ -191,11 +191,30 @@ FAKE_DIMMABLE_FAN = BulbType( fan_reverse=True, ), kelvin_range=KelvinRange(max=2700, min=2700), - fw_version="1.31.32", + fw_version="1.34.1", white_channels=1, white_to_color_ratio=20, fan_speed_range=6, ) +FAKE_DIMMABLE_FAN_2 = BulbType( + bulb_type=BulbClass.FANDIM, + name="ESP20_FANDIMS_31", + features=Features( + color=False, + color_tmp=False, + effect=False, + brightness=True, + dual_head=False, + fan=True, + fan_breeze_mode=True, + fan_reverse=True, + ), + kelvin_range=None, + fw_version="1.34.0", + white_channels=None, + white_to_color_ratio=None, + fan_speed_range=6, +) async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr index 2c6b235e78b..bc8dfb6ce79 100644 --- a/tests/components/wiz/snapshots/test_fan.ambr +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entity[fan.mock_title-entry] +# name: test_entity[bulb_type0][fan.mock_title-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -38,7 +38,68 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[fan.mock_title-state] +# name: test_entity[bulb_type0][fan.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'Mock Title', + 'percentage': 16, + 'percentage_step': 16.666666666666668, + 'preset_mode': None, + 'preset_modes': list([ + 'breeze', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity[bulb_type1][fan.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'breeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.mock_title', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'wiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'abcabcabcabc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[bulb_type1][fan.mock_title-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'direction': 'forward', diff --git a/tests/components/wiz/test_fan.py b/tests/components/wiz/test_fan.py index d15f083d431..42c022f2c45 100644 --- a/tests/components/wiz/test_fan.py +++ b/tests/components/wiz/test_fan.py @@ -3,6 +3,8 @@ from typing import Any from unittest.mock import patch +import pytest +from pywizlight import BulbType from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( @@ -28,7 +30,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration +from . import ( + FAKE_DIMMABLE_FAN, + FAKE_DIMMABLE_FAN_2, + FAKE_MAC, + async_push_update, + async_setup_integration, +) from tests.common import snapshot_platform @@ -43,12 +51,16 @@ INITIAL_PARAMS = { } +@pytest.mark.parametrize("bulb_type", [FAKE_DIMMABLE_FAN, FAKE_DIMMABLE_FAN_2]) @patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN]) async def test_entity( - hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + bulb_type: BulbType, ) -> None: """Test the fan entity.""" - entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1] + entry = (await async_setup_integration(hass, bulb_type=bulb_type))[1] await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From 6a42de1e219cd6d76a94fb3f8606e6724082f823 Mon Sep 17 00:00:00 2001 From: Tero Paloheimo Date: Sat, 17 Jan 2026 19:33:29 +0200 Subject: [PATCH 149/163] Bump xiaomi-ble to 1.4.3 (#161132) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 513c2c72994..306fd03662e 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["xiaomi-ble==1.4.1"] + "requirements": ["xiaomi-ble==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6fe3a1b2217..e32a7f7eb5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3213,7 +3213,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.4.1 +xiaomi-ble==1.4.3 # homeassistant.components.knx xknx==3.13.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ddaa060b3d..beb260b0846 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2683,7 +2683,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.4.1 +xiaomi-ble==1.4.3 # homeassistant.components.knx xknx==3.13.0 From 22ff086dc1215f8f229f04233315ccd668cacbc3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 18 Jan 2026 01:12:43 -0800 Subject: [PATCH 150/163] Bump opower to 0.16.4 (#161153) --- 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 19a692fa643..9c9b55920a7 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.16.3"] + "requirements": ["opower==0.16.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e32a7f7eb5c..39148e0e3f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.16.3 +opower==0.16.4 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index beb260b0846..0e047ff0de0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.16.3 +opower==0.16.4 # homeassistant.components.oralb oralb-ble==1.0.2 From 2cb9bbf949225fbfc1d429f1905c75bacbd615f3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:22:34 +0100 Subject: [PATCH 151/163] Fix detection of multiple smart object types in single event (#161189) Co-authored-by: RaHehl Co-authored-by: Joostlek --- .../components/unifiprotect/binary_sensor.py | 36 +++- .../unifiprotect/test_binary_sensor.py | 176 ++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d288fe66e2b..6e9d0640e8c 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -8,6 +8,7 @@ import dataclasses from uiprotect.data import ( NVR, Camera, + Event, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -644,6 +645,31 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): self._attr_is_on = False self._attr_extra_state_attributes = {} + @callback + def _find_active_event_with_object_type( + self, device: ProtectDeviceType + ) -> Event | None: + """Find an active event containing this sensor's object type. + + Fallback for issue #152133: last_smart_detect_event_ids may not update + immediately when a new detection type is added to an ongoing event. + """ + obj_type = self.entity_description.ufp_obj_type + if obj_type is None or not isinstance(device, Camera): + return None + + # Check known active event IDs from camera first (fast path) + for event_id in device.last_smart_detect_event_ids.values(): + if ( + event_id + and (event := self.data.api.bootstrap.events.get(event_id)) + and event.end is None + and obj_type in event.smart_detect_types + ): + return event + + return None + @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: description = self.entity_description @@ -651,9 +677,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): prev_event = self._event prev_event_end = self._event_end super()._async_update_device_from_protect(device) - if event := description.get_event_obj(device): + + event = description.get_event_obj(device) + if event is None: + # Fallback for #152133: check active events directly + event = self._find_active_event_with_object_type(device) + + if event: self._event = event - self._event_end = event.end if event else None + self._event_end = event.end if not ( event diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 0c4d6e00066..4f7e326aeeb 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -721,3 +721,179 @@ async def test_binary_sensor_person_detected( ufp.ws_msg(mock_msg) await hass.async_block_till_done() assert len(state_changes) == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_simultaneous_person_and_vehicle_detection( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test that when an event is updated with additional detection types, both trigger. + + This is a regression test for https://github.com/home-assistant/core/issues/152133 + where an event starting with vehicle detection gets updated to also include person + detection (e.g., someone getting out of a car). Both sensors should be ON + simultaneously, not queued. + """ + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15) + + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.VEHICLE) + + # Get entity IDs for both person and vehicle detection + _, person_entity_id = await ids_from_device_description( + hass, + Platform.BINARY_SENSOR, + doorbell, + EVENT_SENSORS[3], # person detected + ) + _, vehicle_entity_id = await ids_from_device_description( + hass, + Platform.BINARY_SENSOR, + doorbell, + EVENT_SENSORS[4], # vehicle detected + ) + + # Step 1: Initial event with only VEHICLE detection (car arriving) + event = Event( + model=ModelType.EVENT, + id="combined_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=None, # Event is ongoing + score=90, + smart_detect_types=[SmartDetectObjectType.VEHICLE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.model_copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Vehicle sensor should be ON + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_ON, "Vehicle detection should be ON" + + # Person sensor should still be OFF (no person detected yet) + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_OFF, "Person detection should be OFF initially" + + # Step 2: Same event gets updated to include PERSON detection + # (someone gets out of the car - Protect adds PERSON to the same event) + # + # BUG SCENARIO: UniFi Protect updates the event to include PERSON in + # smart_detect_types, BUT does NOT update last_smart_detect_event_ids[PERSON] + # until the event ends. This is the core issue reported in #152133. + updated_event = Event( + model=ModelType.EVENT, + id="combined_event_id", # Same event ID! + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=None, # Event still ongoing + score=90, + smart_detect_types=[ + SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.PERSON, + ], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + # IMPORTANT: The camera's last_smart_detect_event_ids is NOT updated for PERSON! + # This simulates the real bug where UniFi Protect doesn't immediately update + # the camera's last_smart_detect_event_ids when a new detection type is added + # to an ongoing event. + new_camera = doorbell.model_copy() + new_camera.is_smart_detected = True + # Only VEHICLE has the event ID - PERSON does not (simulating the bug) + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = ( + updated_event.id + ) + # NOTE: We're NOT setting last_smart_detect_event_ids[PERSON] to simulate the bug! + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {updated_event.id: updated_event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = updated_event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # CRITICAL: Both sensors should now be ON simultaneously + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_ON, ( + "Vehicle detection should still be ON after event update" + ) + + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_ON, ( + "Person detection should be ON immediately when added to event, " + "not waiting for vehicle detection to end" + ) + + # Verify both have correct attributes + assert vehicle_state.attributes[ATTR_EVENT_SCORE] == 90 + assert person_state.attributes[ATTR_EVENT_SCORE] == 90 + + # Step 3: Event ends - both sensors should turn OFF + ended_event = Event( + model=ModelType.EVENT, + id="combined_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=fixed_now, # Event ended now + score=90, + smart_detect_types=[ + SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.PERSON, + ], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {ended_event.id: ended_event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = ended_event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Both should be OFF now + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_OFF, ( + "Vehicle detection should be OFF after event ends" + ) + + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_OFF, ( + "Person detection should be OFF after event ends" + ) From 9eb398a9533e60f230bef879b42340eecacc0fc0 Mon Sep 17 00:00:00 2001 From: Jacob <630000+stickpin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:37:19 +0100 Subject: [PATCH 152/163] Fix icons for 'moving' state (#161194) --- homeassistant/components/binary_sensor/icons.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json index a457fa667ed..966e2adb5a1 100644 --- a/homeassistant/components/binary_sensor/icons.json +++ b/homeassistant/components/binary_sensor/icons.json @@ -85,9 +85,9 @@ } }, "moving": { - "default": "mdi:arrow-right", + "default": "mdi:octagon", "state": { - "on": "mdi:octagon" + "on": "mdi:arrow-right" } }, "occupancy": { From 59319f56bf8ac479cf93c7c8e60e40ec9b443074 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 20 Jan 2026 21:14:58 +0100 Subject: [PATCH 153/163] Bump onedrive-personal-sdk to 0.1.1 (#161337) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 982fceb1e04..79be8d85b55 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.1.0"] + "requirements": ["onedrive-personal-sdk==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 39148e0e3f1..b0508fe18f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1644,7 +1644,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.1.0 +onedrive-personal-sdk==0.1.1 # homeassistant.components.onvif onvif-zeep-async==4.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e047ff0de0..a2b91a4ab10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1427,7 +1427,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.1.0 +onedrive-personal-sdk==0.1.1 # homeassistant.components.onvif onvif-zeep-async==4.0.4 From 865d2e4b6e82fd3a8852370dd664af2095dabbc6 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 21 Jan 2026 00:59:46 +0100 Subject: [PATCH 154/163] Bump uiprotect to 10.0.0 (#161350) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 4753419fd0c..b21340d9e4e 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==10.0.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b0508fe18f7..58347dbd6dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==8.1.1 +uiprotect==10.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2b91a4ab10..0a2535fb5b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2569,7 +2569,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==8.1.1 +uiprotect==10.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 594cd572de857e9eeb4eed1269519669e8cf21b0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 22 Jan 2026 05:36:21 +0100 Subject: [PATCH 155/163] Migrate config entries to string unique id (#161370) --- homeassistant/components/arve/__init__.py | 21 +++++++++++ homeassistant/components/arve/config_flow.py | 5 ++- .../components/microbees/__init__.py | 20 +++++++++++ .../components/microbees/config_flow.py | 4 ++- homeassistant/components/monzo/__init__.py | 21 +++++++++++ homeassistant/components/monzo/config_flow.py | 4 ++- homeassistant/components/toon/__init__.py | 8 +++++ homeassistant/components/toon/config_flow.py | 3 +- tests/components/arve/conftest.py | 5 ++- tests/components/arve/test_config_flow.py | 2 +- tests/components/arve/test_init.py | 26 ++++++++++++++ tests/components/microbees/conftest.py | 2 +- .../components/microbees/test_config_flow.py | 4 +-- tests/components/microbees/test_init.py | 35 +++++++++++++++++++ tests/components/monzo/test_config_flow.py | 2 +- tests/components/monzo/test_init.py | 28 ++++++++++++++- tests/components/toon/test_config_flow.py | 4 +-- tests/components/toon/test_init.py | 26 ++++++++++++++ 18 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 tests/components/arve/test_init.py create mode 100644 tests/components/microbees/test_init.py diff --git a/homeassistant/components/arve/__init__.py b/homeassistant/components/arve/__init__.py index c5900967bde..8a7c826f6c4 100644 --- a/homeassistant/components/arve/__init__.py +++ b/homeassistant/components/arve/__init__.py @@ -2,14 +2,35 @@ from __future__ import annotations +import logging + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .coordinator import ArveConfigEntry, ArveCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.SENSOR] +async def async_migrate_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version == 1: + # 1 -> 1.2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool: """Set up Arve from a config entry.""" diff --git a/homeassistant/components/arve/config_flow.py b/homeassistant/components/arve/config_flow.py index 23d344d2325..466ed7bad5f 100644 --- a/homeassistant/components/arve/config_flow.py +++ b/homeassistant/components/arve/config_flow.py @@ -19,6 +19,9 @@ _LOGGER = logging.getLogger(__name__) class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Arve.""" + VERSION = 1 + MINOR_VERSION = 2 + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -35,7 +38,7 @@ class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN): except ArveConnectionError: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(customer.customerId) + await self.async_set_unique_id(str(customer.customerId)) self._abort_if_unique_id_configured() return self.async_create_entry( title="Arve", diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py index 12c536121da..56ce18a0286 100644 --- a/homeassistant/components/microbees/__init__.py +++ b/homeassistant/components/microbees/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from http import HTTPStatus +import logging import aiohttp from microBeesPy import MicroBees @@ -15,6 +16,8 @@ from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, PLATFORMS from .coordinator import MicroBeesUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class HomeAssistantMicroBeesData: @@ -25,6 +28,23 @@ class HomeAssistantMicroBeesData: session: config_entry_oauth2_flow.OAuth2Session +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version == 1: + # 1 -> 1.2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up microBees from a config entry.""" implementation = ( diff --git a/homeassistant/components/microbees/config_flow.py b/homeassistant/components/microbees/config_flow.py index 92fa40b24f0..66ed6427e30 100644 --- a/homeassistant/components/microbees/config_flow.py +++ b/homeassistant/components/microbees/config_flow.py @@ -19,6 +19,8 @@ class OAuth2FlowHandler( """Handle a config flow for microBees.""" DOMAIN = DOMAIN + VERSION = 1 + MINOR_VERSION = 2 @property def logger(self) -> logging.Logger: @@ -47,7 +49,7 @@ class OAuth2FlowHandler( self.logger.exception("Unexpected error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(current_user.id) + await self.async_set_unique_id(str(current_user.id)) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index 662cfecd2e9..ebac75721e5 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -15,9 +17,28 @@ from .api import AuthenticatedMonzoAPI from .const import DOMAIN from .coordinator import MonzoCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.SENSOR] +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version == 1: + # 1 -> 1.2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Monzo from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) diff --git a/homeassistant/components/monzo/config_flow.py b/homeassistant/components/monzo/config_flow.py index 9f005c6aaa4..32bb29dafd7 100644 --- a/homeassistant/components/monzo/config_flow.py +++ b/homeassistant/components/monzo/config_flow.py @@ -21,6 +21,8 @@ class MonzoFlowHandler( """Handle a config flow.""" DOMAIN = DOMAIN + VERSION = 1 + MINOR_VERSION = 2 oauth_data: dict[str, Any] @@ -51,7 +53,7 @@ class MonzoFlowHandler( """Create an entry for the flow.""" self.oauth_data = data user_id = data[CONF_TOKEN]["user_id"] - await self.async_set_unique_id(user_id) + await self.async_set_unique_id(str(user_id)) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() else: diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 8e208ff8096..919a146ec93 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -83,6 +83,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False + if entry.version == 2: + # 2 -> 2.2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + return True diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py index 450d2472a6c..ab5ff6d87e3 100644 --- a/homeassistant/components/toon/config_flow.py +++ b/homeassistant/components/toon/config_flow.py @@ -20,6 +20,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): DOMAIN = DOMAIN VERSION = 2 + MINOR_VERSION = 2 agreements: list[Agreement] data: dict[str, Any] @@ -92,7 +93,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if self.migrate_entry: await self.hass.config_entries.async_remove(self.migrate_entry) - await self.async_set_unique_id(agreement.agreement_id) + await self.async_set_unique_id(str(agreement.agreement_id)) self._abort_if_unique_id_configured() self.data[CONF_AGREEMENT_ID] = agreement.agreement_id diff --git a/tests/components/arve/conftest.py b/tests/components/arve/conftest.py index 8fc35e37000..843e0a6f32c 100644 --- a/tests/components/arve/conftest.py +++ b/tests/components/arve/conftest.py @@ -27,7 +27,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_config_entry(hass: HomeAssistant, mock_arve: MagicMock) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title="Arve", domain=DOMAIN, data=USER_INPUT, unique_id=mock_arve.customer_id + title="Arve", + domain=DOMAIN, + data=USER_INPUT, + unique_id=str(mock_arve.customer_id), ) diff --git a/tests/components/arve/test_config_flow.py b/tests/components/arve/test_config_flow.py index efa36e37d44..89c7c14383c 100644 --- a/tests/components/arve/test_config_flow.py +++ b/tests/components/arve/test_config_flow.py @@ -34,7 +34,7 @@ async def test_correct_flow( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["data"] == USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == 12345 + assert result2["result"].unique_id == "12345" async def test_form_cannot_connect( diff --git a/tests/components/arve/test_init.py b/tests/components/arve/test_init.py new file mode 100644 index 00000000000..2b8cc1d5537 --- /dev/null +++ b/tests/components/arve/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the Arve component.""" + +from unittest.mock import patch + +from homeassistant.components.arve.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch("homeassistant.components.arve.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCESS_TOKEN: "mock", CONF_CLIENT_SECRET: "mock"}, + version=1, + minor_version=1, + unique_id=12345, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "12345" diff --git a/tests/components/microbees/conftest.py b/tests/components/microbees/conftest.py index 60df0377e4d..6c2c45232da 100644 --- a/tests/components/microbees/conftest.py +++ b/tests/components/microbees/conftest.py @@ -59,7 +59,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title=TITLE, - unique_id=54321, + unique_id="54321", data={ "auth_implementation": DOMAIN, "token": { diff --git a/tests/components/microbees/test_config_flow.py b/tests/components/microbees/test_config_flow.py index f4e074d000d..da164d61dd1 100644 --- a/tests/components/microbees/test_config_flow.py +++ b/tests/components/microbees/test_config_flow.py @@ -74,7 +74,7 @@ async def test_full_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@microbees.com" assert "result" in result - assert result["result"].unique_id == 54321 + assert result["result"].unique_id == "54321" assert "token" in result["result"].data assert result["result"].data["token"]["access_token"] == "mock-access-token" assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" @@ -197,7 +197,7 @@ async def test_config_reauth_wrong_account( ) -> None: """Test reauth with wrong account.""" await setup_integration(hass, config_entry) - microbees.return_value.getMyProfile.return_value.id = 12345 + microbees.return_value.getMyProfile.return_value.id = "12345" result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" diff --git a/tests/components/microbees/test_init.py b/tests/components/microbees/test_init.py new file mode 100644 index 00000000000..f70c8387572 --- /dev/null +++ b/tests/components/microbees/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the microBees component.""" + +from unittest.mock import patch + +from homeassistant.components.microbees.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch( + "homeassistant.components.microbees.async_setup_entry", return_value=True + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + }, + version=1, + minor_version=1, + unique_id=54321, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "54321" diff --git a/tests/components/monzo/test_config_flow.py b/tests/components/monzo/test_config_flow.py index 7630acfc1cf..0f3cd24e66a 100644 --- a/tests/components/monzo/test_config_flow.py +++ b/tests/components/monzo/test_config_flow.py @@ -244,7 +244,7 @@ async def test_config_reauth_wrong_account( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "user_id": 12346, + "user_id": "12346", }, ) diff --git a/tests/components/monzo/test_init.py b/tests/components/monzo/test_init.py index b24fb6ff86e..f255160f1ed 100644 --- a/tests/components/monzo/test_init.py +++ b/tests/components/monzo/test_init.py @@ -1,7 +1,7 @@ """Tests for component initialisation.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from monzopy import AuthorisationExpiredError @@ -35,3 +35,29 @@ async def test_api_can_trigger_reauth( assert flow["step_id"] == "reauth_confirm" assert flow["handler"] == DOMAIN assert flow["context"]["source"] == SOURCE_REAUTH + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch("homeassistant.components.monzo.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": "600", + }, + }, + version=1, + minor_version=1, + unique_id=600, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "600" diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index affdadd75c2..2c88063f177 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -213,7 +213,7 @@ async def test_agreement_already_set_up( ) -> None: """Test showing display form again if display already exists.""" await setup_component(hass) - MockConfigEntry(domain=DOMAIN, unique_id=123).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, unique_id="123").add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -312,7 +312,7 @@ async def test_import_migration( aioclient_mock: AiohttpClientMocker, ) -> None: """Test if importing step with migration works.""" - old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1) + old_entry = MockConfigEntry(domain=DOMAIN, unique_id="123", version=1) old_entry.add_to_hass(hass) await setup_component(hass) diff --git a/tests/components/toon/test_init.py b/tests/components/toon/test_init.py index 02efc3095a1..72c284a886a 100644 --- a/tests/components/toon/test_init.py +++ b/tests/components/toon/test_init.py @@ -40,3 +40,29 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_migrate_entry_minor_version_2_2(hass: HomeAssistant) -> None: + """Test migrating a 2.1 config entry to 2.2.""" + with patch("homeassistant.components.toon.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + "agreement_id": 123, + }, + version=2, + minor_version=1, + unique_id=123, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 2 + assert entry.minor_version == 2 + assert entry.unique_id == "123" From 2f49dedc63d8b5bc1349c897d3d9db08775485db Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:20:33 +0100 Subject: [PATCH 156/163] Bump uiprotect to 10.0.1 (#161397) Co-authored-by: RaHehl --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b21340d9e4e..cc9adca7052 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.0.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==10.0.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 58347dbd6dc..0f32e54782f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==10.0.0 +uiprotect==10.0.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a2535fb5b8..3f0fef883e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2569,7 +2569,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==10.0.0 +uiprotect==10.0.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From db2fd77c8b81e4c84f872e2168f381c0a3bc6c45 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Thu, 22 Jan 2026 07:48:42 -0500 Subject: [PATCH 157/163] Bump Insteon panel to 0.6.1 (#161411) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 4f7b5d3e03d..b1398326de4 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -19,7 +19,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.6.4", - "insteon-frontend-home-assistant==0.6.0" + "insteon-frontend-home-assistant==0.6.1" ], "single_config_entry": true, "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index 0f32e54782f..f125633a54f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1294,7 +1294,7 @@ influxdb==5.3.1 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.6.0 +insteon-frontend-home-assistant==0.6.1 # homeassistant.components.intellifire intellifire4py==4.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0fef883e2..3a2824e3e18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1140,7 +1140,7 @@ influxdb==5.3.1 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.6.0 +insteon-frontend-home-assistant==0.6.1 # homeassistant.components.intellifire intellifire4py==4.2.1 From fda3a63e82df63758621f36c6499e0e2af77fce8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:12:06 +0100 Subject: [PATCH 158/163] Bump music-assistant-client to 1.3.3 (#161438) --- homeassistant/components/music_assistant/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/music_assistant/test_media_player.py | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 72eb70d1ad0..f5dc0e4a8d1 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.2"], + "requirements": ["music-assistant-client==1.3.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f125633a54f..dc5c6ce6e9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1524,7 +1524,7 @@ mozart-api==5.3.1.108.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.2 +music-assistant-client==1.3.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a2824e3e18..fa7f028875b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ mozart-api==5.3.1.108.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.2 +music-assistant-client==1.3.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 1ec669fbe86..a21b1ccfdbe 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -538,8 +538,9 @@ async def test_media_player_play_announcement_action( "players/cmd/play_announcement", player_id=mass_player_id, url="http://blah.com/announcement.mp3", - use_pre_announce=True, + pre_announce=True, volume_level=50, + pre_announce_url=None, ) From 44ca65b1e186b5dd9016f01f0097b690c361bed6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 22 Jan 2026 18:38:23 +0100 Subject: [PATCH 159/163] Revert deprecation of `server_host` for container installations (#161443) Co-authored-by: Robert Resch --- homeassistant/components/http/__init__.py | 12 +++--------- homeassistant/components/http/strings.json | 4 ---- tests/components/http/test_init.py | 22 +++------------------- 3 files changed, 6 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 12bc5a4f075..75971b1ed1d 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -108,7 +108,6 @@ _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] HTTP_SCHEMA: Final = vol.All( cv.deprecated(CONF_BASE_URL), - cv.deprecated(CONF_SERVER_HOST), # Deprecated in HA Core 2025.12 vol.Schema( { vol.Optional(CONF_SERVER_HOST): vol.All( @@ -209,20 +208,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if conf is None: conf = cast(ConfData, HTTP_SCHEMA({})) - if CONF_SERVER_HOST in conf: - if is_hassio(hass): - issue_id = "server_host_deprecated_hassio" - severity = ir.IssueSeverity.ERROR - else: - issue_id = "server_host_deprecated" - severity = ir.IssueSeverity.WARNING + if CONF_SERVER_HOST in conf and is_hassio(hass): + issue_id = "server_host_deprecated_hassio" ir.async_create_issue( hass, DOMAIN, issue_id, breaks_in_ha_version="2026.6.0", is_fixable=False, - severity=severity, + severity=ir.IssueSeverity.ERROR, translation_key=issue_id, ) diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index f9d47466942..b74cfd457b2 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -1,9 +1,5 @@ { "issues": { - "server_host_deprecated": { - "description": "The `server_host` configuration option in the HTTP integration is deprecated and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", - "title": "The `server_host` HTTP configuration option is deprecated" - }, "server_host_deprecated_hassio": { "description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", "title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9f9af5896db..2f7517f8ecb 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -683,26 +683,18 @@ async def test_ssl_issue_urls_configured( "hassio", "http_config", "expected_serverhost", - "expected_warning_count", "expected_issues", ), [ - (False, {}, ["0.0.0.0", "::"], 0, set()), - ( - False, - {"server_host": "0.0.0.0"}, - ["0.0.0.0"], - 1, - {("http", "server_host_deprecated")}, - ), - (True, {}, ["0.0.0.0", "::"], 0, set()), + (False, {}, ["0.0.0.0", "::"], set()), + (False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()), + (True, {}, ["0.0.0.0", "::"], set()), ( True, {"server_host": "0.0.0.0"}, [ "0.0.0.0", ], - 1, {("http", "server_host_deprecated_hassio")}, ), ], @@ -713,7 +705,6 @@ async def test_server_host( issue_registry: ir.IssueRegistry, http_config: dict, expected_serverhost: list, - expected_warning_count: int, expected_issues: set[tuple[str, str]], caplog: pytest.LogCaptureFixture, ) -> None: @@ -743,11 +734,4 @@ async def test_server_host( reuse_port=None, ) - assert ( - caplog.text.count( - "The 'server_host' option is deprecated, please remove it from your configuration" - ) - == expected_warning_count - ) - assert set(issue_registry.issues) == expected_issues From 0867807ba66732917aa7faa37203edf6da1ead2a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 22 Jan 2026 13:57:14 -0800 Subject: [PATCH 160/163] Bump opower to 0.16.5 (#161450) --- 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 9c9b55920a7..dd56fc2ef53 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.16.4"] + "requirements": ["opower==0.16.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc5c6ce6e9a..69262282617 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.16.4 +opower==0.16.5 # homeassistant.components.oralb oralb-ble==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7f028875b..06e6e1c6e0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1455,7 +1455,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.16.4 +opower==0.16.5 # homeassistant.components.oralb oralb-ble==1.0.2 From 7e3b9e1d0f2ba7addcf34d1b5407497cbfccff00 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Jan 2026 17:57:17 +0000 Subject: [PATCH 161/163] Bump version to 2026.1.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 15ec98c953d..6f42a791c24 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 1 -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 142174002bb..347b65accbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.1.2" +version = "2026.1.3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 363712488165e6ae17ca645c9d1ba9d480d10081 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:47:00 +0100 Subject: [PATCH 162/163] Add selenium to FORBIDDEN_PACKAGE_EXCEPTIONS (#161216) --- script/hassfest/requirements.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index f13654d751c..ee02896b6f0 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -224,6 +224,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "sense": {"sense-energy": {"async-timeout"}}, "slimproto": {"aioslimproto": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, + "tami4": { + # https://github.com/SeleniumHQ/selenium/issues/16943 + # tami4 > selenium > types* + "selenium": {"types-certifi", "types-urllib3"}, + }, "travisci": { # https://github.com/menegazzo/travispy seems to be unmaintained # and unused https://www.home-assistant.io/integrations/travisci From 7ce46678568f96370f63fc9500598369c079452e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Jan 2026 19:16:11 +0000 Subject: [PATCH 163/163] Update wiz snapshots --- tests/components/wiz/snapshots/test_fan.ambr | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr index bc8dfb6ce79..8ed058349c6 100644 --- a/tests/components/wiz/snapshots/test_fan.ambr +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -84,7 +84,6 @@ 'labels': set({ }), 'name': None, - 'object_id_base': None, 'options': dict({ }), 'original_device_class': None,