From 59105925cd45f813f1238ab6912ea724b4fb8942 Mon Sep 17 00:00:00 2001 From: Martin Hoefling Date: Sun, 20 Sep 2026 13:46:50 +0200 Subject: [PATCH 01/21] Bump knx-telegram-store to 0.14.0 (#182758) --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/telegrams.py | 5 +++++ requirements_all.txt | 2 +- tests/components/knx/test_telegrams.py | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 11d9cab56322b0..616ac8682a5ec6 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.20.0", "xknxproject==3.10.0", "knx-frontend==2026.9.4.63549", - "knx-telegram-store[sqlite,postgres]==0.11.2" + "knx-telegram-store[sqlite,postgres]==0.14.0" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 31997bf1be9f83..0afcea0ed37fe9 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -142,6 +142,11 @@ def __init__( retention_days=self.retention_days, flush_interval=FLUSH_INTERVAL_SECONDS_SQLITE, max_buffer_size=MAX_BUFFER_TELEGRAMS_SQLITE, + # Databases written before knx-telegram-store 0.14 hold local + # wall-clock times with no offset. Only we know which zone wrote + # them - it is ours, not necessarily the host's - so the store + # is told once and converts them on this start. + legacy_timestamp_timezone=dt_util.get_default_time_zone(), ) self._xknx_telegram_cb_handle = ( diff --git a/requirements_all.txt b/requirements_all.txt index 8d3bbe845bf5c8..0eb9ded828fd86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1487,7 +1487,7 @@ knocki==0.4.2 knx-frontend==2026.9.4.63549 # homeassistant.components.knx -knx-telegram-store[sqlite,postgres]==0.11.2 +knx-telegram-store[sqlite,postgres]==0.14.0 # homeassistant.components.kraken krakenex==2.2.2 diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 9f985accabebdc..311afc6ab4db6b 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -167,6 +167,22 @@ async def test_store_telegram_history_error_handling( assert issue is not None +async def test_sqlite_store_is_told_the_configured_timezone( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test the SQLite store receives Home Assistant's configured time zone.""" + await hass.config.async_set_time_zone("Europe/Berlin") + await knx.setup_integration(real_telegram_store=True) + + store = hass.data[KNX_MODULE_KEY].telegrams.store + assert store is not None + # The zone the store was handed, read back off the real store rather than + # off a mock: the store fixture rebuilds the class during setup, so a patch + # placed around setup_integration never sees the call. + assert store._legacy_timestamp_timezone is dt_util.get_default_time_zone() + + async def test_store_telegram_history_needs_migration_timeout( hass: HomeAssistant, knx: KNXTestKit, From a74b99f94a99a7fe3461c78493ded806bf8fb7b9 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Sun, 20 Sep 2026 14:50:22 +0100 Subject: [PATCH 02/21] Set up all Hive platforms so unload and services stay consistent (#182765) Signed-off-by: Tom Wilkie --- homeassistant/components/hive/__init__.py | 11 +---- .../components/hive/binary_sensor.py | 4 +- homeassistant/components/hive/const.py | 8 ---- tests/components/hive/test_init.py | 41 ++++++++++++++++++- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index d50ead9bfaa53c..7c73712403cce1 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS +from .const import DOMAIN, PLATFORMS from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) @@ -60,14 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool manufacturer=hub_data["deviceData"]["manufacturer"], ) - await hass.config_entries.async_forward_entry_setups( - entry, - [ - ha_type - for ha_type, hive_type in PLATFORM_LOOKUP.items() - if devices.get(hive_type) - ], - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 3393924ba4f18f..f0a9da64af326e 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -77,7 +77,7 @@ async def async_setup_entry( sensors: list[BinarySensorEntity] = [] - devices = hive.session.deviceList.get("binary_sensor") + devices = hive.session.deviceList.get("binary_sensor", []) sensors.extend( HiveBinarySensorEntity(hass, entry, hive, dev, description) for dev in devices @@ -85,7 +85,7 @@ async def async_setup_entry( if dev["hiveType"] == description.key ) - devices = hive.session.deviceList.get("sensor") + devices = hive.session.deviceList.get("sensor", []) sensors.extend( HiveSensorEntity(hass, entry, hive, dev, description) for dev in devices diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index a14d222471898e..84cecc62d245d5 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -17,14 +17,6 @@ Platform.SWITCH, Platform.WATER_HEATER, ] -PLATFORM_LOOKUP = { - Platform.BINARY_SENSOR: "binary_sensor", - Platform.CLIMATE: "climate", - Platform.LIGHT: "light", - Platform.SENSOR: "sensor", - Platform.SWITCH: "switch", - Platform.WATER_HEATER: "water_heater", -} SERVICE_BOOST_HOT_WATER = "boost_hot_water" SERVICE_BOOST_HEATING_ON = "boost_heating_on" SERVICE_BOOST_HEATING_OFF = "boost_heating_off" diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py index 936052be72179b..56774eaf103689 100644 --- a/tests/components/hive/test_init.py +++ b/tests/components/hive/test_init.py @@ -3,8 +3,16 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.hive.const import DOMAIN +from homeassistant.components.hive.const import ( + DOMAIN, + SERVICE_BOOST_HEATING_OFF, + SERVICE_BOOST_HEATING_ON, + SERVICE_BOOST_HOT_WATER, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_CLASS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -244,3 +252,34 @@ async def test_glass_break_device_class( state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK + + +async def test_all_platforms_forwarded_without_devices( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """All platforms are set up and the entry reloads on a device-less account.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive({}) + + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + # Entity services must not depend on device discovery. + assert hass.services.has_service(DOMAIN, SERVICE_BOOST_HOT_WATER) + assert hass.services.has_service(DOMAIN, SERVICE_BOOST_HEATING_ON) + assert hass.services.has_service(DOMAIN, SERVICE_BOOST_HEATING_OFF) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert "Error setting up entry" not in caplog.text + assert "Error unloading entry" not in caplog.text From ef97d7c3f84dc1491a6817928a4e6d36e01c8f9b Mon Sep 17 00:00:00 2001 From: Sean Cardus Date: Sun, 20 Sep 2026 14:50:48 +0100 Subject: [PATCH 03/21] Fix friends' achievement stats in Xbox integration (#182686) --- homeassistant/components/xbox/coordinator.py | 4 +- tests/components/xbox/conftest.py | 7 + .../xbox/fixtures/people_friends_own.json | 22 +-- .../titlehub_titleinfo_2533274913657542.json | 89 +++++++++ .../titlehub_titleinfo_271958441785640.json | 186 ++++++++++++++++++ .../xbox/snapshots/test_binary_sensor.ambr | 4 +- .../xbox/snapshots/test_diagnostics.ambr | 117 +++++++++-- .../xbox/snapshots/test_sensor.ambr | 25 +-- 8 files changed, 416 insertions(+), 38 deletions(-) create mode 100644 tests/components/xbox/fixtures/titlehub_titleinfo_2533274913657542.json create mode 100644 tests/components/xbox/fixtures/titlehub_titleinfo_271958441785640.json diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index bf6eb7f7360b71..e67000a7d5a099 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -228,8 +228,8 @@ async def update_data(self) -> XboxData: ): continue try: - title = await self.client.titlehub.get_title_info( - presence_detail.title_id + title = await self.client.titlehub.get_title_info_by_xuid( + person.xuid, presence_detail.title_id ) except HTTPStatusError as e: if e.response.status_code == HTTPStatus.NOT_FOUND: diff --git a/tests/components/xbox/conftest.py b/tests/components/xbox/conftest.py index 0ae6d269fe1e54..4704906f461e7e 100644 --- a/tests/components/xbox/conftest.py +++ b/tests/components/xbox/conftest.py @@ -191,6 +191,13 @@ def mock_xbox_live_client() -> Generator[AsyncMock]: client.titlehub.get_title_info.return_value = TitleHubResponse( **load_json_object_fixture("titlehub_titleinfo.json", DOMAIN) ) + + def get_title_info_by_xuid(xuid: str, _: str) -> TitleHubResponse: + return TitleHubResponse( + **load_json_object_fixture(f"titlehub_titleinfo_{xuid}.json", DOMAIN) + ) + + client.titlehub.get_title_info_by_xuid.side_effect = get_title_info_by_xuid client.titlehub.get_title_history.return_value = TitleHubResponse( **load_json_object_fixture("titlehub_titlehistory.json", DOMAIN) ) diff --git a/tests/components/xbox/fixtures/people_friends_own.json b/tests/components/xbox/fixtures/people_friends_own.json index 0466df0cd99430..040767ff7aff23 100644 --- a/tests/components/xbox/fixtures/people_friends_own.json +++ b/tests/components/xbox/fixtures/people_friends_own.json @@ -133,8 +133,8 @@ "modernGamertagSuffix": "", "uniqueModernGamertag": "erics273", "xboxOneRep": "GoodPlayer", - "presenceState": "Offline", - "presenceText": "Last seen 17h ago: Home", + "presenceState": "Online", + "presenceText": "Minecraft", "presenceDevices": null, "isFriend": true, "isFriendRequestReceived": false, @@ -174,23 +174,23 @@ { "IsBroadcasting": false, "Device": "XboxOne", - "PresenceText": "Last seen 17h ago: Home", - "State": "LastSeen", - "TitleId": "750323071", + "PresenceText": "Minecraft", + "State": "Active", + "TitleId": "1739947436", "TitleType": null, "IsPrimary": true, - "IsGame": false, + "IsGame": true, "RichPresenceText": null } ], "titlePresence": { - "IsCurrentlyPlaying": false, - "PresenceText": null, - "TitleName": null, - "TitleId": null + "IsCurrentlyPlaying": true, + "PresenceText": "Minecraft", + "TitleName": "Minecraft", + "TitleId": "1739947436" }, "titleSummaries": null, - "presenceTitleIds": ["750323071"], + "presenceTitleIds": ["1739947436"], "detail": { "accountTier": "Silver", "bio": "", diff --git a/tests/components/xbox/fixtures/titlehub_titleinfo_2533274913657542.json b/tests/components/xbox/fixtures/titlehub_titleinfo_2533274913657542.json new file mode 100644 index 00000000000000..7fa64e6b72eb6d --- /dev/null +++ b/tests/components/xbox/fixtures/titlehub_titleinfo_2533274913657542.json @@ -0,0 +1,89 @@ +{ + "xuid": "2533274913657542", + "titles": [ + { + "titleId": "1739947436", + "pfn": "Microsoft.MinecraftUWP_8wekyb3d8bbwe", + "bingId": "8ff0c8c4-2b7f-4e2e-9ab9-2cd9ac0d1b4b", + "serviceConfigId": "4fc10100-5f7a-4470-899b-280835760c07", + "windowsPhoneProductId": null, + "name": "Minecraft", + "type": "Game", + "devices": ["XboxOne", "XboxSeries", "Win32", "Android", "iOS"], + "displayImage": "http://store-images.s-microsoft.com/image/apps.50293.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.98b3c9d5-1f3d-4d1e-9fdd-c1c1e7d0bd57", + "mediaItemType": "XboxOneGame", + "modernTitleId": "1739947436", + "isBundle": false, + "achievement": { + "currentAchievements": 57, + "totalAchievements": 121, + "currentGamerscore": 1105, + "totalGamerscore": 2500, + "progressPercentage": 44.0, + "sourceVersion": 2 + }, + "stats": null, + "gamePass": null, + "images": [ + { + "url": "http://store-images.s-microsoft.com/image/apps.50293.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.98b3c9d5-1f3d-4d1e-9fdd-c1c1e7d0bd57", + "type": "BoxArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.11144.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.6a2c9c4f-1a34-4a3f-9a2e-0f3a5a07a6bd", + "type": "Logo", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.27886.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.5a9d2a8e-64f2-4f9a-9a2b-9d2a7f18e3c1", + "type": "TitledHeroArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.63217.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.3d8e6b5c-7c1a-42f9-84d6-0f9f4a1c2b77", + "type": "Poster", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.19442.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.c47a1d09-52b7-4d0e-9a7f-2e5b1a6d8f34", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.38014.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.0b6e9f21-4a3c-4b8d-bc57-7a1e9d3f6c20", + "type": "Screenshot", + "caption": "" + } + ], + "titleHistory": null, + "titleRecord": null, + "detail": { + "attributes": [], + "availabilities": [ + { + "Actions": ["Details", "Purchase", "Browse"], + "AvailabilityId": "9NBLGGH537B3", + "Platforms": ["Xbox"], + "SkuId": "0010", + "ProductId": "9NBLGGH537B3" + } + ], + "capabilities": [], + "description": "Explore infinite worlds and build everything from the simplest of homes to the grandest of castles. Play in creative mode with unlimited resources or mine deep into the world in survival mode, crafting weapons and armor to fend off dangerous mobs.", + "developerName": "Mojang Studios", + "genres": ["Action & adventure", "Family & kids"], + "minAge": 7, + "publisherName": "Xbox Game Studios", + "releaseDate": "2017-09-19T07:00:00Z", + "shortDescription": "Explore infinite worlds and build everything from the simplest of homes to the grandest of castles.", + "vuiDisplayName": "Minecraft", + "xboxLiveGoldRequired": false + }, + "friendsWhoPlayed": null, + "alternateTitleIds": [], + "contentBoards": null, + "xboxLiveTier": "Full" + } + ] +} diff --git a/tests/components/xbox/fixtures/titlehub_titleinfo_271958441785640.json b/tests/components/xbox/fixtures/titlehub_titleinfo_271958441785640.json new file mode 100644 index 00000000000000..79373dba93fb3f --- /dev/null +++ b/tests/components/xbox/fixtures/titlehub_titleinfo_271958441785640.json @@ -0,0 +1,186 @@ +{ + "xuid": "271958441785640", + "titles": [ + { + "titleId": "1297287135", + "pfn": null, + "bingId": "66acd000-77fe-1000-9115-d8024d5307df", + "serviceConfigId": "7f2e0100-2f8c-4b64-b2be-675b266ec2cd", + "windowsPhoneProductId": null, + "name": "Blue Dragon", + "type": "Game", + "devices": ["Xbox360", "XboxOne", "XboxSeries"], + "displayImage": "http://store-images.s-microsoft.com/image/apps.45451.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.3abf2cc3-00cc-417d-a93d-97110cdfb261", + "mediaItemType": "Xbox360Game", + "modernTitleId": "644793037", + "isBundle": false, + "achievement": { + "currentAchievements": 2, + "totalAchievements": 43, + "currentGamerscore": 10, + "totalGamerscore": 1000, + "progressPercentage": 1.0, + "sourceVersion": 1 + }, + "stats": null, + "gamePass": null, + "images": [ + { + "url": "http://store-images.s-microsoft.com/image/apps.35725.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.c4bf34f8-ad40-4af3-914e-a85e75a76bed", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.64736.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.6491fb2f-52e7-4129-bcbd-d23a67117ae0", + "type": "BrandedKeyArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.55545.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.4c2daefb-fbf6-4b90-b392-bf8ecc39a92e", + "type": "TitledHeroArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.22570.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.bf29284d-808a-4e4a-beaa-6621c9898d0e", + "type": "Poster", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.55545.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.4c2daefb-fbf6-4b90-b392-bf8ecc39a92e", + "type": "SuperHeroArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.45451.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.3abf2cc3-00cc-417d-a93d-97110cdfb261", + "type": "BoxArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.35072.13670972585585116.70570f0d-17aa-4f97-b692-5412fa183673.25a97451-9369-4f6b-b66b-3427913235eb", + "type": "Logo", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.45451.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.3abf2cc3-00cc-417d-a93d-97110cdfb261", + "type": "FeaturePromotionalSquareArt", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.38628.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.c2a205af-5146-405b-b2b7-56845351f1f3", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.22150.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.b147895c-e947-424d-a731-faefc8c9906a", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.37559.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.479d2dc1-db2d-4ffa-8c54-a2bebb093ec6", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.32737.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.6a16ae3e-2918-46e9-90d9-232c79cb9d9d", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.57046.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.0c0dd072-aa27-4e83-9010-474dfbb42277", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.19315.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.6293a7b7-07ca-4df0-9eea-6018285a0a8d", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.23374.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.66498a73-52f5-4247-a1e2-d3c84b9b315d", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.64646.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.83182b76-4294-496d-90a7-f4e31e7aa80a", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.24470.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.72d2abc3-aa69-4aeb-960b-6f6d25f498e4", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.15604.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.27cee011-660b-49a4-bd33-38db6fff5226", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.39987.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.be285efe-78f8-4984-9d28-9159881bacd4", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.38206.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.2409803d-7378-4a69-a10b-1574ac42b98b", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.14938.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.ef6ee72c-4beb-45ec-bd10-6235bd6a7c7f", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.12835.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.6165ee24-df01-44f5-80fe-7411f9366d1c", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.40786.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.b7607a0d-0101-4864-9bf8-ad889f820489", + "type": "Screenshot", + "caption": "" + }, + { + "url": "http://store-images.s-microsoft.com/image/apps.55686.65457035095819016.56f55216-1bb9-40aa-8796-068cf3075fc1.ecbb0e91-36a9-4f76-ab1e-5a5de009840e", + "type": "Screenshot", + "caption": "" + } + ], + "titleHistory": null, + "titleRecord": null, + "detail": { + "attributes": [], + "availabilities": [ + { + "Actions": [ + "Details", + "Fulfill", + "Purchase", + "Browse", + "Curate", + "Redeem" + ], + "AvailabilityId": "9TWPXS4C28K1", + "Platforms": ["Xbox"], + "SkuId": "0001", + "ProductId": "C2HGK9J5367F" + } + ], + "capabilities": [], + "description": "Hironobu Sakaguchi, the Father of the Final Fantasy series, and Artoon present the world of Blue Dragon on Xbox 360. Featuring the character design of Akira Toriyama, the Father of \"Dragon Ball Z\", and music by Nobuo Uematsu, the composer for Final Fantasy, Blue Dragon is an epic role-playing game (RPG) centered on five youngsters who possess miraculous strength and magical power to control shadow creatures that mirror the actions of their masters.", + "developerName": "Mistwalker / Artoon", + "genres": ["Role Playing"], + "minAge": 13, + "publisherName": "Microsoft", + "releaseDate": "2016-12-14T09:00:00Z", + "shortDescription": "Hironobu Sakaguchi, the Father of the Final Fantasy series, and Artoon present the world of Blue Dragon on Xbox 360. Featuring the character design of Akira Toriyama, the Father of \"Dragon Ball Z\", and music by Nobuo Uematsu, the composer for Final Fantasy, Blue Dragon is an epic role-playing game (RPG) centered on five youngsters who possess miraculous strength and magical power to control shadow creatures that mirror the actions of their masters.", + "vuiDisplayName": "Blue Dragon", + "xboxLiveGoldRequired": false + }, + "friendsWhoPlayed": null, + "alternateTitleIds": [], + "contentBoards": null, + "xboxLiveTier": "Full" + } + ] +} diff --git a/tests/components/xbox/snapshots/test_binary_sensor.ambr b/tests/components/xbox/snapshots/test_binary_sensor.ambr index 6a89059c578957..27577fde82a9b8 100644 --- a/tests/components/xbox/snapshots/test_binary_sensor.ambr +++ b/tests/components/xbox/snapshots/test_binary_sensor.ambr @@ -51,7 +51,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_binary_sensors[binary_sensor.erics273_in_game-entry] @@ -101,7 +101,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_binary_sensors[binary_sensor.gsr_ae-entry] diff --git a/tests/components/xbox/snapshots/test_diagnostics.ambr b/tests/components/xbox/snapshots/test_diagnostics.ambr index 4937e24ef0f89b..0f6f3e99a49acd 100644 --- a/tests/components/xbox/snapshots/test_diagnostics.ambr +++ b/tests/components/xbox/snapshots/test_diagnostics.ambr @@ -9623,20 +9623,20 @@ 'device_sub_type': None, 'gameplay_type': None, 'is_broadcasting': False, - 'is_game': False, + 'is_game': True, 'is_primary': True, - 'presence_text': 'Last seen 17h ago: Home', + 'presence_text': 'Minecraft', 'rich_presence_text': None, - 'state': 'LastSeen', - 'title_id': '750323071', + 'state': 'Active', + 'title_id': '1739947436', 'title_type': None, }), ]), 'presence_devices': None, - 'presence_state': 'Offline', - 'presence_text': 'Last seen 17h ago: Home', + 'presence_state': 'Online', + 'presence_text': 'Minecraft', 'presence_title_ids': list([ - '750323071', + '1739947436', ]), 'real_name': '', 'recent_player': dict({ @@ -9661,10 +9661,10 @@ }), 'titleHistory': None, 'title_presence': dict({ - 'is_currently_playing': False, - 'presence_text': None, - 'title_id': None, - 'title_name': None, + 'is_currently_playing': True, + 'presence_text': 'Minecraft', + 'title_id': '1739947436', + 'title_name': 'Minecraft', }), 'title_summaries': None, 'tournament_summary': None, @@ -9841,6 +9841,101 @@ 'windows_phone_product_id': None, 'xbox_live_tier': 'Full', }), + dict({ + 'achievement': dict({ + 'current_achievements': 57, + 'current_gamerscore': 1105, + 'progress_percentage': 44.0, + 'source_version': 2, + 'total_achievements': 121, + 'total_gamerscore': 2500, + }), + 'alternate_title_ids': list([ + ]), + 'bing_id': '8ff0c8c4-2b7f-4e2e-9ab9-2cd9ac0d1b4b', + 'content_boards': None, + 'detail': dict({ + 'attributes': list([ + ]), + 'availabilities': list([ + dict({ + 'actions': list([ + 'Details', + 'Purchase', + 'Browse', + ]), + 'availability_id': '9NBLGGH537B3', + 'platforms': list([ + 'Xbox', + ]), + 'sku_id': '0010', + }), + ]), + 'capabilities': list([ + ]), + 'description': 'Explore infinite worlds and build everything from the simplest of homes to the grandest of castles. Play in creative mode with unlimited resources or mine deep into the world in survival mode, crafting weapons and armor to fend off dangerous mobs.', + 'developer_name': 'Mojang Studios', + 'genres': list([ + 'Action & adventure', + 'Family & kids', + ]), + 'min_age': 7, + 'publisher_name': 'Xbox Game Studios', + 'release_date': '2017-09-19T07:00:00+00:00', + 'short_description': 'Explore infinite worlds and build everything from the simplest of homes to the grandest of castles.', + 'vui_display_name': 'Minecraft', + 'xbox_live_gold_required': False, + }), + 'devices': list([ + 'XboxOne', + 'XboxSeries', + 'Win32', + 'Android', + 'iOS', + ]), + 'display_image': 'http://store-images.s-microsoft.com/image/apps.50293.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.98b3c9d5-1f3d-4d1e-9fdd-c1c1e7d0bd57', + 'friends_who_played': None, + 'game_pass': None, + 'images': list([ + dict({ + 'type': 'BoxArt', + 'url': 'http://store-images.s-microsoft.com/image/apps.50293.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.98b3c9d5-1f3d-4d1e-9fdd-c1c1e7d0bd57', + }), + dict({ + 'type': 'Logo', + 'url': 'http://store-images.s-microsoft.com/image/apps.11144.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.6a2c9c4f-1a34-4a3f-9a2e-0f3a5a07a6bd', + }), + dict({ + 'type': 'TitledHeroArt', + 'url': 'http://store-images.s-microsoft.com/image/apps.27886.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.5a9d2a8e-64f2-4f9a-9a2b-9d2a7f18e3c1', + }), + dict({ + 'type': 'Poster', + 'url': 'http://store-images.s-microsoft.com/image/apps.63217.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.3d8e6b5c-7c1a-42f9-84d6-0f9f4a1c2b77', + }), + dict({ + 'type': 'Screenshot', + 'url': 'http://store-images.s-microsoft.com/image/apps.19442.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.c47a1d09-52b7-4d0e-9a7f-2e5b1a6d8f34', + }), + dict({ + 'type': 'Screenshot', + 'url': 'http://store-images.s-microsoft.com/image/apps.38014.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.0b6e9f21-4a3c-4b8d-bc57-7a1e9d3f6c20', + }), + ]), + 'is_bundle': False, + 'is_streamable': None, + 'media_item_type': 'XboxOneGame', + 'modern_title_id': '1739947436', + 'name': 'Minecraft', + 'pfn': 'Microsoft.MinecraftUWP_8wekyb3d8bbwe', + 'service_config_id': '4fc10100-5f7a-4470-899b-280835760c07', + 'stats': None, + 'title_history': None, + 'title_id': '1739947436', + 'type': 'Game', + 'windows_phone_product_id': None, + 'xbox_live_tier': 'Full', + }), ]), }) # --- diff --git a/tests/components/xbox/snapshots/test_sensor.ambr b/tests/components/xbox/snapshots/test_sensor.ambr index f0302ce70ddb02..8993c7cc4dbb7e 100644 --- a/tests/components/xbox/snapshots/test_sensor.ambr +++ b/tests/components/xbox/snapshots/test_sensor.ambr @@ -357,24 +357,25 @@ # name: test_sensors[sensor.erics273_now_playing-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'achievements': None, - 'developer': None, + 'achievements': '57 / 121', + 'developer': 'Mojang Studios', + : 'https://store-images.s-microsoft.com/image/apps.11144.13851379342944008.b2fc5ef4-8c3f-4b02-8c42-1e88e52dd0f1.6a2c9c4f-1a34-4a3f-9a2e-0f3a5a07a6bd', : 'erics273 Now playing', - 'gamerscore': None, - 'genres': None, - 'min_age': None, - 'platform': None, - 'progress': None, - 'publisher': None, - 'release_date': None, - 'short_description': None, + 'gamerscore': '1105 / 2500', + 'genres': 'Action & adventure, Family & kids', + 'min_age': 7, + 'platform': 'Xbox One', + 'progress': '44 %', + 'publisher': 'Xbox Game Studios', + 'release_date': datetime.date(2017, 9, 19), + 'short_description': 'Explore infinite worlds and build everything from the simplest of homes to the grandest of castles.', }), 'context': , 'entity_id': 'sensor.erics273_now_playing', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Minecraft', }) # --- # name: test_sensors[sensor.erics273_party_join_restrictions-entry] @@ -484,7 +485,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Last seen 17h ago: Home', + 'state': 'Minecraft', }) # --- # name: test_sensors[sensor.gsr_ae_followers-entry] From fbaee67d45d66e1caa1223debe8315fbd135ce62 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Sep 2026 15:52:28 +0200 Subject: [PATCH 04/21] async_listen and async_listen_once now errors if used with run_immediately (#182763) --- homeassistant/core.py | 10 ++++++---- tests/test_core.py | 10 +++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 34fd164c47dcb0..4887d7003281b3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1728,8 +1728,9 @@ def async_listen( frame.report_usage( "calls `async_listen` with run_immediately", - core_behavior=frame.ReportBehavior.LOG, - breaks_in_ha_version="2025.5", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.ERROR, ) if event_filter is not None and not is_callback_check_partial(event_filter): @@ -1798,8 +1799,9 @@ def async_listen_once( frame.report_usage( "calls `async_listen_once` with run_immediately", - core_behavior=frame.ReportBehavior.LOG, - breaks_in_ha_version="2025.5", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.ERROR, ) one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener( diff --git a/tests/test_core.py b/tests/test_core.py index 697d655acb14a2..6a9aa701968232 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3529,11 +3529,11 @@ async def _test(event: ha.Event): pass func = getattr(hass.bus, method) - func(EVENT_HOMEASSISTANT_START, _test, run_immediately=run_immediately) - assert ( - f"Detected code that calls `{method}` with run_immediately. " - "This will stop working in Home Assistant 2025.5" - ) in caplog.text + with pytest.raises( + RuntimeError, + match=f"Detected code that calls `{method}` with run_immediately. Please report this issue", + ): + func(EVENT_HOMEASSISTANT_START, _test, run_immediately=run_immediately) async def test_async_fire_thread_safety(hass: HomeAssistant) -> None: From 7709e2e08570cd0f3de24c01319147dc855bf314 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 20 Sep 2026 15:55:06 +0200 Subject: [PATCH 05/21] Bump brother to 6.2.0 (#182755) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/brother/snapshots/test_diagnostics.ambr | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 78502db6912a6e..5cd81423a0d85f 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==6.1.2"], + "requirements": ["brother==6.2.0"], "zeroconf": [ { "name": "brother*", diff --git a/requirements_all.txt b/requirements_all.txt index 0eb9ded828fd86..e52e82d0d28ac0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -743,7 +743,7 @@ bring-api==1.1.2 broadlink==0.19.0 # homeassistant.components.brother -brother==6.1.2 +brother==6.2.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 diff --git a/tests/components/brother/snapshots/test_diagnostics.ambr b/tests/components/brother/snapshots/test_diagnostics.ambr index 2bd9adffbe1138..d5b827fd9ee381 100644 --- a/tests/components/brother/snapshots/test_diagnostics.ambr +++ b/tests/components/brother/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'cyan_toner': 10, 'cyan_toner_remaining': 10, 'cyan_toner_status': 1, + 'device_status': None, 'drum_counter': 986, 'drum_remaining_life': 92, 'drum_remaining_pages': 11014, @@ -34,6 +35,7 @@ 'fuser_remaining_life': 97, 'fuser_unit_remaining_pages': None, 'image_counter': None, + 'ink_capture_box_remaining_life': None, 'laser_remaining_life': None, 'laser_unit_remaining_pages': 48389, 'magenta_counter': None, @@ -51,6 +53,8 @@ 'pf_kit_1_remaining_pages': 48741, 'pf_kit_mp_remaining_life': None, 'pf_kit_mp_remaining_pages': None, + 'printer_errors': None, + 'printer_status': None, 'status': 'waiting', 'uptime': '2024-03-03T15:04:24+00:00', 'yellow_counter': None, From eea4b0aca9e389f35fc7ff42bf500f8d159bac64 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 20 Sep 2026 15:12:47 +0100 Subject: [PATCH 06/21] Bump aiomealie to 2.1.1 (#182749) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- .../mealie/fixtures/get_recipe.json | 41 ++++- .../mealie/snapshots/test_diagnostics.ambr | 12 ++ .../mealie/snapshots/test_services.ambr | 158 +++++++++++++++++- 5 files changed, 206 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 0d9613d6e15637..24fcd3c1538e32 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==2.0.0"] + "requirements": ["aiomealie==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e52e82d0d28ac0..5a22f56c5a5a40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -354,7 +354,7 @@ aiolookin==1.0.0 aiolyric==2.1.2 # homeassistant.components.mealie -aiomealie==2.0.0 +aiomealie==2.1.1 # homeassistant.components.melcloud_home aiomelcloudhome==0.2.3 diff --git a/tests/components/mealie/fixtures/get_recipe.json b/tests/components/mealie/fixtures/get_recipe.json index 61bf187b80ed8c..6622fa6c2900c0 100644 --- a/tests/components/mealie/fixtures/get_recipe.json +++ b/tests/components/mealie/fixtures/get_recipe.json @@ -249,19 +249,34 @@ "id": "2d558dbf-5361-4ef2-9d86-4161f5eb6146", "title": "", "text": "Preheat oven to 170°C. Line the base of a springform with baking paper, grease the sides, and dust with a little flour. Melt couverture over boiling water. Let cool slightly.", - "ingredientReferences": [] + "ingredientReferences": [ + { + "referenceId": "a3adfe78-d157-44d8-98be-9c133e45bb4e" + }, + { + "referenceId": "ef023f23-7816-4871-87f6-4d29f9a283f7" + } + ], + "noteReferences": [ + { + "referenceId": "d2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032" + }, + { + "referenceId": "f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143" + } + ] }, { "id": "dbcc1c37-3cbf-4045-9902-8f7fd1e68f0a", "title": "", "text": "Slit vanilla pod lengthwise and scrape out seeds. Using a hand mixer with whisks, beat the softened butter with the icing sugar and vanilla seeds until bubbles appear.", - "ingredientReferences": [] + "ingredientReferences": [], + "noteReferences": [] }, { "id": "2265bd14-a691-40b1-9fe6-7b5dfeac8401", "title": "", - "text": "Separate the eggs. Whisk the egg yolks into the butter mixture one by one. Now gradually add melted couverture chocolate. Beat the egg whites with the castor sugar until stiff, then place on top of the butter and chocolate mixture. Sift the flour over the mixture, then fold in the flour and beaten egg whites.", - "ingredientReferences": [] + "text": "Separate the eggs. Whisk the egg yolks into the butter mixture one by one. Now gradually add melted couverture chocolate. Beat the egg whites with the castor sugar until stiff, then place on top of the butter and chocolate mixture. Sift the flour over the mixture, then fold in the flour and beaten egg whites." }, { "id": "0aade447-dfac-4aae-8e67-ac250ad13ae2", @@ -313,7 +328,23 @@ "locked": false }, "assets": [], - "notes": [], + "notes": [ + { + "title": "Baking tip", + "text": "Use room-temperature ingredients for a smoother batter.", + "referenceId": "d2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032" + }, + { + "title": "Cooling", + "text": "Let the cake cool completely before adding the glaze.", + "referenceId": "f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143" + }, + { + "title": "Serving", + "text": "Serve with unsweetened whipped cream.", + "referenceId": "9c4e2a71-6d38-4f05-b927-1a8e3c6d5024" + } + ], "extras": { "Energy Consumption": "5" }, diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 546416b635e319..811521ecf3d940 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -794,6 +794,8 @@ 'name': 'can', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', 'updated_at': '2024-05-14T14:45:02.464124', 'use_abbreviation': False, @@ -875,6 +877,8 @@ 'name': 'US cup', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', 'updated_at': '2024-08-25T13:29:25.477535+00:00', 'use_abbreviation': False, @@ -947,6 +951,8 @@ 'name': 'can', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', 'updated_at': '2024-05-14T14:45:02.464124', 'use_abbreviation': False, @@ -1028,6 +1034,8 @@ 'name': 'US cup', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', 'updated_at': '2024-08-25T13:29:25.477535+00:00', 'use_abbreviation': False, @@ -1100,6 +1108,8 @@ 'name': 'can', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', 'updated_at': '2024-05-14T14:45:02.464124', 'use_abbreviation': False, @@ -1181,6 +1191,8 @@ 'name': 'US cup', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', 'updated_at': '2024-08-25T13:29:25.477535+00:00', 'use_abbreviation': False, diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index ce9b3e35397935..5a29a4c92dfbc9 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -3528,6 +3528,8 @@ 'name': 'can', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464124), 'use_abbreviation': False, @@ -3609,6 +3611,8 @@ 'name': 'US cup', 'plural_abbreviation': '', 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477535, tzinfo=datetime.timezone.utc), 'use_abbreviation': False, @@ -3792,14 +3796,45 @@ 'reference_id': '82f534de-ecef-4470-9d7a-6e24ded4a53a', 'referenced_recipe': None, 'title': '', - 'unit': "{'id': '34a3e8e3-c405-49fc-9fec-92dd5241a5c4', 'name': 'millilitre', 'pluralName': None, 'description': '', 'extras': {}, 'fraction': True, 'abbreviation': 'ml', 'pluralAbbreviation': '', 'useAbbreviation': False, 'aliases': [], 'createdAt': '2024-08-25T13:29:25.641593Z', 'updatedAt': '2024-08-25T13:29:25.641610Z'}", + 'unit': dict({ + 'abbreviation': 'ml', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 641593, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'millilitre', + 'plural_abbreviation': '', + 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, + 'unit_id': '34a3e8e3-c405-49fc-9fec-92dd5241a5c4', + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 641610, tzinfo=datetime.timezone.utc), + 'use_abbreviation': False, + }), }), ]), 'instructions': list([ dict({ 'ingredient_references': list([ + 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'ef023f23-7816-4871-87f6-4d29f9a283f7', ]), 'instruction_id': '2d558dbf-5361-4ef2-9d86-4161f5eb6146', + 'recipe_notes': list([ + dict({ + 'reference_id': 'd2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032', + 'text': 'Use room-temperature ingredients for a smoother batter.', + 'title': 'Baking tip', + }), + dict({ + 'reference_id': 'f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143', + 'text': 'Let the cake cool completely before adding the glaze.', + 'title': 'Cooling', + }), + ]), 'text': 'Preheat oven to 170°C. Line the base of a springform with baking paper, grease the sides, and dust with a little flour. Melt couverture over boiling water. Let cool slightly.', 'title': None, }), @@ -3807,6 +3842,8 @@ 'ingredient_references': list([ ]), 'instruction_id': 'dbcc1c37-3cbf-4045-9902-8f7fd1e68f0a', + 'recipe_notes': list([ + ]), 'text': 'Slit vanilla pod lengthwise and scrape out seeds. Using a hand mixer with whisks, beat the softened butter with the icing sugar and vanilla seeds until bubbles appear.', 'title': None, }), @@ -3814,6 +3851,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '2265bd14-a691-40b1-9fe6-7b5dfeac8401', + 'recipe_notes': list([ + ]), 'text': 'Separate the eggs. Whisk the egg yolks into the butter mixture one by one. Now gradually add melted couverture chocolate. Beat the egg whites with the castor sugar until stiff, then place on top of the butter and chocolate mixture. Sift the flour over the mixture, then fold in the flour and beaten egg whites.', 'title': None, }), @@ -3821,6 +3860,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '0aade447-dfac-4aae-8e67-ac250ad13ae2', + 'recipe_notes': list([ + ]), 'text': "Transfer the mixture to the springform, smooth the top, and bake in the oven (middle rack) for 10–15 minutes, leaving the oven door a finger's width ajar. Then close the oven and bake for approximately 50 minutes. (The cake is done when it yields slightly to the touch.)", 'title': None, }), @@ -3828,6 +3869,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '5fdcb703-7103-468d-a65d-a92460b92eb3', + 'recipe_notes': list([ + ]), 'text': 'Remove the cake from the oven and loosen the sides of the springform. Carefully tip the cake onto a cake rack lined with baking paper and let cool for approximately 20 minutes. Then pull off the baking paper, turn the cake over, and leave on rack to cool completely.', 'title': None, }), @@ -3835,6 +3878,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '81474afc-b44e-49b3-bb67-5d7dab8f832a', + 'recipe_notes': list([ + ]), 'text': 'Cut the cake in half horizontally. Warm the jam and stir until smooth. Brush the top of both cake halves with the jam and place one on top of the other. Brush the sides with the jam as well.', 'title': None, }), @@ -3842,6 +3887,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '8fac8aee-0d3c-4f78-9ff8-56d20472e5f1', + 'recipe_notes': list([ + ]), 'text': 'To make the glaze, put the castor sugar into a saucepan with 125 ml water and boil over high heat for approximately 5 minutes. Take the sugar syrup off the stove and leave to cool a little. Coarsely chop the couverture, gradually adding it to the syrup, and stir until it forms a thick liquid (see tip below).', 'title': None, }), @@ -3849,12 +3896,44 @@ 'ingredient_references': list([ ]), 'instruction_id': '7162e099-d651-4656-902a-a09a9b40c4e1', + 'recipe_notes': list([ + ]), 'text': 'Pour all the lukewarm glaze liquid at once over the top of the cake and quickly spread using a palette knife. Leave the glaze to set for a few hours. Serve garnished with whipped cream.', 'title': None, }), ]), 'last_made': None, 'name': 'Original Sacher-Torte (2)', + 'notes': list([ + dict({ + 'reference_id': 'd2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032', + 'text': 'Use room-temperature ingredients for a smoother batter.', + 'title': 'Baking tip', + }), + dict({ + 'reference_id': 'f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143', + 'text': 'Let the cake cool completely before adding the glaze.', + 'title': 'Cooling', + }), + dict({ + 'reference_id': '9c4e2a71-6d38-4f05-b927-1a8e3c6d5024', + 'text': 'Serve with unsweetened whipped cream.', + 'title': 'Serving', + }), + ]), + 'nutrition': dict({ + 'calories': '400', + 'carbohydrate_content': None, + 'cholesterol_content': None, + 'fat_content': '17', + 'fiber_content': None, + 'protein_content': None, + 'saturated_fat_content': None, + 'sodium_content': None, + 'sugar_content': None, + 'trans_fat_content': None, + 'unsaturated_fat_content': None, + }), 'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/', 'perform_time': '1 hour', 'prep_time': '1 hour 30 minutes', @@ -4731,14 +4810,45 @@ 'reference_id': '82f534de-ecef-4470-9d7a-6e24ded4a53a', 'referenced_recipe': None, 'title': '', - 'unit': "{'id': '34a3e8e3-c405-49fc-9fec-92dd5241a5c4', 'name': 'millilitre', 'pluralName': None, 'description': '', 'extras': {}, 'fraction': True, 'abbreviation': 'ml', 'pluralAbbreviation': '', 'useAbbreviation': False, 'aliases': [], 'createdAt': '2024-08-25T13:29:25.641593Z', 'updatedAt': '2024-08-25T13:29:25.641610Z'}", + 'unit': dict({ + 'abbreviation': 'ml', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 641593, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'millilitre', + 'plural_abbreviation': '', + 'plural_name': None, + 'standard_quantity': None, + 'standard_unit': None, + 'unit_id': '34a3e8e3-c405-49fc-9fec-92dd5241a5c4', + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 641610, tzinfo=datetime.timezone.utc), + 'use_abbreviation': False, + }), }), ]), 'instructions': list([ dict({ 'ingredient_references': list([ + 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'ef023f23-7816-4871-87f6-4d29f9a283f7', ]), 'instruction_id': '2d558dbf-5361-4ef2-9d86-4161f5eb6146', + 'recipe_notes': list([ + dict({ + 'reference_id': 'd2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032', + 'text': 'Use room-temperature ingredients for a smoother batter.', + 'title': 'Baking tip', + }), + dict({ + 'reference_id': 'f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143', + 'text': 'Let the cake cool completely before adding the glaze.', + 'title': 'Cooling', + }), + ]), 'text': 'Preheat oven to 170°C. Line the base of a springform with baking paper, grease the sides, and dust with a little flour. Melt couverture over boiling water. Let cool slightly.', 'title': None, }), @@ -4746,6 +4856,8 @@ 'ingredient_references': list([ ]), 'instruction_id': 'dbcc1c37-3cbf-4045-9902-8f7fd1e68f0a', + 'recipe_notes': list([ + ]), 'text': 'Slit vanilla pod lengthwise and scrape out seeds. Using a hand mixer with whisks, beat the softened butter with the icing sugar and vanilla seeds until bubbles appear.', 'title': None, }), @@ -4753,6 +4865,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '2265bd14-a691-40b1-9fe6-7b5dfeac8401', + 'recipe_notes': list([ + ]), 'text': 'Separate the eggs. Whisk the egg yolks into the butter mixture one by one. Now gradually add melted couverture chocolate. Beat the egg whites with the castor sugar until stiff, then place on top of the butter and chocolate mixture. Sift the flour over the mixture, then fold in the flour and beaten egg whites.', 'title': None, }), @@ -4760,6 +4874,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '0aade447-dfac-4aae-8e67-ac250ad13ae2', + 'recipe_notes': list([ + ]), 'text': "Transfer the mixture to the springform, smooth the top, and bake in the oven (middle rack) for 10–15 minutes, leaving the oven door a finger's width ajar. Then close the oven and bake for approximately 50 minutes. (The cake is done when it yields slightly to the touch.)", 'title': None, }), @@ -4767,6 +4883,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '5fdcb703-7103-468d-a65d-a92460b92eb3', + 'recipe_notes': list([ + ]), 'text': 'Remove the cake from the oven and loosen the sides of the springform. Carefully tip the cake onto a cake rack lined with baking paper and let cool for approximately 20 minutes. Then pull off the baking paper, turn the cake over, and leave on rack to cool completely.', 'title': None, }), @@ -4774,6 +4892,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '81474afc-b44e-49b3-bb67-5d7dab8f832a', + 'recipe_notes': list([ + ]), 'text': 'Cut the cake in half horizontally. Warm the jam and stir until smooth. Brush the top of both cake halves with the jam and place one on top of the other. Brush the sides with the jam as well.', 'title': None, }), @@ -4781,6 +4901,8 @@ 'ingredient_references': list([ ]), 'instruction_id': '8fac8aee-0d3c-4f78-9ff8-56d20472e5f1', + 'recipe_notes': list([ + ]), 'text': 'To make the glaze, put the castor sugar into a saucepan with 125 ml water and boil over high heat for approximately 5 minutes. Take the sugar syrup off the stove and leave to cool a little. Coarsely chop the couverture, gradually adding it to the syrup, and stir until it forms a thick liquid (see tip below).', 'title': None, }), @@ -4788,12 +4910,44 @@ 'ingredient_references': list([ ]), 'instruction_id': '7162e099-d651-4656-902a-a09a9b40c4e1', + 'recipe_notes': list([ + ]), 'text': 'Pour all the lukewarm glaze liquid at once over the top of the cake and quickly spread using a palette knife. Leave the glaze to set for a few hours. Serve garnished with whipped cream.', 'title': None, }), ]), 'last_made': None, 'name': 'Original Sacher-Torte (2)', + 'notes': list([ + dict({ + 'reference_id': 'd2b7f3c8-4a6e-4f91-9b25-8c1d7e6a5032', + 'text': 'Use room-temperature ingredients for a smoother batter.', + 'title': 'Baking tip', + }), + dict({ + 'reference_id': 'f6a1c9e4-7b32-4d85-a018-2e9c5b7d6143', + 'text': 'Let the cake cool completely before adding the glaze.', + 'title': 'Cooling', + }), + dict({ + 'reference_id': '9c4e2a71-6d38-4f05-b927-1a8e3c6d5024', + 'text': 'Serve with unsweetened whipped cream.', + 'title': 'Serving', + }), + ]), + 'nutrition': dict({ + 'calories': '400', + 'carbohydrate_content': None, + 'cholesterol_content': None, + 'fat_content': '17', + 'fiber_content': None, + 'protein_content': None, + 'saturated_fat_content': None, + 'sodium_content': None, + 'sugar_content': None, + 'trans_fat_content': None, + 'unsaturated_fat_content': None, + }), 'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/', 'perform_time': '1 hour', 'prep_time': '1 hour 30 minutes', From 701da374cf2a12863a2366dd203c68c49fd2970b Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:16:36 -0400 Subject: [PATCH 07/21] Update subaru integration codeowners (#182732) --- CODEOWNERS | 4 ++-- homeassistant/components/subaru/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1496f15a4c5496..111b8e4c7120a1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1817,8 +1817,8 @@ CLAUDE.md @home-assistant/core /tests/components/stream/ @hunterjm @uvjustin @allenporter /homeassistant/components/stt/ @home-assistant/core /tests/components/stt/ @home-assistant/core -/homeassistant/components/subaru/ @G-Two -/tests/components/subaru/ @G-Two +/homeassistant/components/subaru/ @G-Two @jpettitt +/tests/components/subaru/ @G-Two @jpettitt /homeassistant/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/sun/ @home-assistant/core diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 775d492165c953..eec35f15a175c1 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -1,7 +1,7 @@ { "domain": "subaru", "name": "Subaru", - "codeowners": ["@G-Two"], + "codeowners": ["@G-Two", "@jpettitt"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/subaru", "integration_type": "hub", From bb7b66c37461ef58665d014ace4760e287f20be6 Mon Sep 17 00:00:00 2001 From: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:27:31 +0100 Subject: [PATCH 08/21] Raise HomeAssistantError when a LIFX Cloud scene fails to activate (#182756) --- homeassistant/components/lifx_cloud/const.py | 3 ++ homeassistant/components/lifx_cloud/scene.py | 12 +++-- .../components/lifx_cloud/strings.json | 7 +++ tests/components/lifx_cloud/__init__.py | 1 + tests/components/lifx_cloud/test_scene.py | 46 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/lifx_cloud/const.py create mode 100644 homeassistant/components/lifx_cloud/strings.json create mode 100644 tests/components/lifx_cloud/__init__.py create mode 100644 tests/components/lifx_cloud/test_scene.py diff --git a/homeassistant/components/lifx_cloud/const.py b/homeassistant/components/lifx_cloud/const.py new file mode 100644 index 00000000000000..f81809dda8c411 --- /dev/null +++ b/homeassistant/components/lifx_cloud/const.py @@ -0,0 +1,3 @@ +"""Constants for the LIFX Cloud integration.""" + +DOMAIN = "lifx_cloud" diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index 968d51759c3d40..6efb8a433fe2f8 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -12,11 +12,14 @@ from homeassistant.components.scene import Scene from homeassistant.const import CONF_PLATFORM, CONF_TIMEOUT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 @@ -93,6 +96,9 @@ async def async_activate(self, **kwargs: Any) -> None: async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) - # pylint: disable-next=home-assistant-action-swallowed-exception - except TimeoutError, aiohttp.ClientError: - _LOGGER.exception("Error on %s", url) + except (TimeoutError, aiohttp.ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="activate_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/lifx_cloud/strings.json b/homeassistant/components/lifx_cloud/strings.json new file mode 100644 index 00000000000000..1f2de9d192f5fd --- /dev/null +++ b/homeassistant/components/lifx_cloud/strings.json @@ -0,0 +1,7 @@ +{ + "exceptions": { + "activate_failed": { + "message": "Failed to activate the LIFX Cloud scene: {error}" + } + } +} diff --git a/tests/components/lifx_cloud/__init__.py b/tests/components/lifx_cloud/__init__.py new file mode 100644 index 00000000000000..57f0b7a411fbe0 --- /dev/null +++ b/tests/components/lifx_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the LIFX Cloud integration.""" diff --git a/tests/components/lifx_cloud/test_scene.py b/tests/components/lifx_cloud/test_scene.py new file mode 100644 index 00000000000000..3a94f22ce70a7c --- /dev/null +++ b/tests/components/lifx_cloud/test_scene.py @@ -0,0 +1,46 @@ +"""Test the LIFX Cloud scene.""" + +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest + +from homeassistant.components.lifx_cloud.scene import LifxCloudScene +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +SCENE_DATA = {"name": "Living room", "uuid": "abc-123"} + + +@pytest.fixture +def scene(hass: HomeAssistant) -> LifxCloudScene: + """Return a LIFX Cloud scene entity.""" + return LifxCloudScene(hass, {"Authorization": "Bearer token"}, 10, SCENE_DATA) + + +async def test_activate(scene: LifxCloudScene) -> None: + """The activate request goes out to the cloud.""" + with patch( + "homeassistant.components.lifx_cloud.scene.async_get_clientsession" + ) as session: + session.return_value.put = AsyncMock() + await scene.async_activate() + + assert session.return_value.put.call_count == 1 + + +@pytest.mark.parametrize( + "error", + [ + pytest.param(TimeoutError(), id="timeout"), + pytest.param(aiohttp.ClientError("boom"), id="client_error"), + ], +) +async def test_activate_failure_raises(scene: LifxCloudScene, error: Exception) -> None: + """A failed activate raises HomeAssistantError.""" + with patch( + "homeassistant.components.lifx_cloud.scene.async_get_clientsession" + ) as session: + session.return_value.put = AsyncMock(side_effect=error) + with pytest.raises(HomeAssistantError): + await scene.async_activate() From 78269936daa98bd6fd350b7b472aa611f364ea9e Mon Sep 17 00:00:00 2001 From: Rasad Regmi <123284094+rasadregmi@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:32:38 +0545 Subject: [PATCH 09/21] Fix swallowed exceptions in ifttt action handlers (#182747) --- homeassistant/components/ifttt/__init__.py | 9 ++++-- homeassistant/components/ifttt/strings.json | 5 ++++ tests/components/ifttt/test_init.py | 33 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 24ced8070f66db..4d9e1126390f42 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -86,9 +87,11 @@ def trigger_service(call: ServiceCall) -> None: res = pyfttt.send_event(key, event, value1, value2, value3) if res.status_code != HTTPStatus.OK: _LOGGER.error("IFTTT reported error sending event to %s", target) - # pylint: disable-next=home-assistant-action-swallowed-exception - except requests.exceptions.RequestException: - _LOGGER.exception("Error communicating with IFTTT") + except requests.exceptions.RequestException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="trigger_failed", + ) from err hass.services.async_register( DOMAIN, SERVICE_TRIGGER, trigger_service, schema=SERVICE_TRIGGER_SCHEMA diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 4030480c923597..c9fb89907570f4 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -18,6 +18,11 @@ } } }, + "exceptions": { + "trigger_failed": { + "message": "Failed to trigger the IFTTT webhook." + } + }, "services": { "push_alarm_state": { "description": "Updates the alarm state to the specified value.", diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index ad4f283d5af387..bc7f7e1b00f710 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -1,12 +1,19 @@ """Test the init file of IFTTT.""" +from unittest.mock import patch + +import pytest +import requests + from homeassistant import config_entries from homeassistant.components import ifttt -from homeassistant.components.ifttt import DOMAIN +from homeassistant.components.ifttt import CONF_KEY, DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.core_config import async_process_ha_core_config from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError +from tests.common import async_setup_component from tests.typing import ClientSessionGenerator @@ -51,3 +58,27 @@ def handle_event(event): # Not a dict await client.post(f"/api/webhook/{webhook_id}", json="not a dict") assert len(ifttt_events) == 1 + + +async def test_trigger_service_raises_when_ifttt_unreachable( + hass: HomeAssistant, +) -> None: + """Test trigger_service raises when IFTTT cannot be reached.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_KEY: "secret"}}) + + with ( + patch( + "homeassistant.components.ifttt.pyfttt.send_event", + side_effect=requests.exceptions.ConnectionError, + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + ifttt.SERVICE_TRIGGER, + {"event": "test_event"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "trigger_failed" From 94a4a22bc2dc594f6376db4f3d575641e1eed641 Mon Sep 17 00:00:00 2001 From: Kristy Date: Sun, 20 Sep 2026 22:49:25 +0800 Subject: [PATCH 10/21] Add binary sensors to NexBlue integration (#182572) --- .../components/nexblue/binary_sensor.py | 91 ++++++++++++++++ homeassistant/components/nexblue/const.py | 6 +- homeassistant/components/nexblue/icons.json | 8 ++ homeassistant/components/nexblue/strings.json | 4 + .../nexblue/snapshots/test_binary_sensor.ambr | 102 ++++++++++++++++++ .../components/nexblue/test_binary_sensor.py | 98 +++++++++++++++++ 6 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/nexblue/binary_sensor.py create mode 100644 tests/components/nexblue/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/nexblue/test_binary_sensor.py diff --git a/homeassistant/components/nexblue/binary_sensor.py b/homeassistant/components/nexblue/binary_sensor.py new file mode 100644 index 00000000000000..5b253294781f4a --- /dev/null +++ b/homeassistant/components/nexblue/binary_sensor.py @@ -0,0 +1,91 @@ +"""Binary sensors for the NexBlue integration.""" + +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NexBlueConfigEntry, NexBlueDataUpdateCoordinator + +BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_lock", + translation_key="is_lock", + device_class=BinarySensorDeviceClass.LOCK, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="is_disable", + translation_key="is_disable", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NexBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NexBlue binary sensors for every discovered charger.""" + coordinator = entry.runtime_data + async_add_entities( + NexBlueBinarySensor(coordinator, serial_number, description) + for serial_number in coordinator.data + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class NexBlueBinarySensor( + CoordinatorEntity[NexBlueDataUpdateCoordinator], BinarySensorEntity +): + """Expose NexBlue charger boolean telemetry.""" + + _attr_has_entity_name = True + entity_description: BinarySensorEntityDescription + + def __init__( + self, + coordinator: NexBlueDataUpdateCoordinator, + serial_number: str, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a binary sensor for one charger metric.""" + super().__init__(coordinator) + self._serial_number = serial_number + self.entity_description = description + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer="NexBlue", + name=serial_number, + serial_number=serial_number, + ) + + @property + @override + def available(self) -> bool: + """Return whether this charger is currently reachable.""" + return ( + super().available + and self.coordinator.data.get(self._serial_number) is not None + ) + + @property + @override + def is_on(self) -> bool: + """Return the binary sensor state.""" + status = self.coordinator.data[self._serial_number] + assert status is not None + if self.entity_description.key == "is_disable": + return not status.is_disable + return not status.is_lock diff --git a/homeassistant/components/nexblue/const.py b/homeassistant/components/nexblue/const.py index b158ea66319761..f857b66ca7c7ae 100644 --- a/homeassistant/components/nexblue/const.py +++ b/homeassistant/components/nexblue/const.py @@ -9,5 +9,9 @@ CONF_REFRESH_TOKEN = "refresh_token" DEFAULT_API_URL = "https://api.nexblue.com/third_party" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] UPDATE_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/nexblue/icons.json b/homeassistant/components/nexblue/icons.json index 76fb9f90be14bf..ba0775c3516b30 100755 --- a/homeassistant/components/nexblue/icons.json +++ b/homeassistant/components/nexblue/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "is_disable": { + "default": "mdi:power", + "state": { + "off": "mdi:power-off" + } + } + }, "sensor": { "access_level": { "default": "mdi:account-lock" diff --git a/homeassistant/components/nexblue/strings.json b/homeassistant/components/nexblue/strings.json index caaf0f16d7171e..063e69f1750bbe 100644 --- a/homeassistant/components/nexblue/strings.json +++ b/homeassistant/components/nexblue/strings.json @@ -23,6 +23,10 @@ } }, "entity": { + "binary_sensor": { + "is_disable": { "name": "Charging enabled" }, + "is_lock": { "name": "Cable lock state" } + }, "sensor": { "access_level": { "name": "Access level", diff --git a/tests/components/nexblue/snapshots/test_binary_sensor.ambr b/tests/components/nexblue/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..65e78430659e1f --- /dev/null +++ b/tests/components/nexblue/snapshots/test_binary_sensor.ambr @@ -0,0 +1,102 @@ +# serializer version: 1 +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_cable_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nb123456_cable_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cable lock state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cable lock state', + 'platform': 'nexblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_lock', + 'unique_id': 'NB123456_is_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_cable_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'lock', + : 'NB123456 Cable lock state', + }), + 'context': , + 'entity_id': 'binary_sensor.nb123456_cable_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_charging_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nb123456_charging_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging enabled', + 'platform': 'nexblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_disable', + 'unique_id': 'NB123456_is_disable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_charging_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NB123456 Charging enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.nb123456_charging_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nexblue/test_binary_sensor.py b/tests/components/nexblue/test_binary_sensor.py new file mode 100644 index 00000000000000..116905deac4a54 --- /dev/null +++ b/tests/components/nexblue/test_binary_sensor.py @@ -0,0 +1,98 @@ +"""Tests for NexBlue binary sensors.""" + +from collections.abc import Generator +from dataclasses import replace +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from nexblue_api import NexBlueConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import CHARGER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def fixture_platforms() -> Generator[None]: + """Limit this module's setup to the binary sensor platform.""" + with patch("homeassistant.components.nexblue.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + +async def test_binary_sensor_entities_snapshot( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the complete NexBlue binary sensor platform through a snapshot.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + init_integration.entry_id, + ) + + +async def test_cable_lock_state_is_on_when_charger_is_unlocked( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test cable lock state is on when the cable is unlocked.""" + mock_client.async_get_charger_status.return_value = replace( + CHARGER_STATUS, is_lock=False + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.nb123456_cable_lock_state").state == "on" + + +async def test_charging_enabled_is_off_when_charger_is_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test charging enabled is off when the charger is disabled.""" + mock_client.async_get_charger_status.return_value = replace( + CHARGER_STATUS, is_disable=True + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.nb123456_charging_enabled").state == "off" + + +async def test_binary_sensors_unavailable_when_coordinator_update_fails( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a failed coordinator update makes binary sensors unavailable.""" + mock_client.async_list_chargers.side_effect = NexBlueConnectionError + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.nb123456_cable_lock_state").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("binary_sensor.nb123456_charging_enabled").state + == STATE_UNAVAILABLE + ) From 123a469bd503f559fc2fd85b6c504bd4e91b1e72 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:49:55 +0200 Subject: [PATCH 11/21] Add UniFi Protect numbers to the API key only mode (#182726) --- .../components/unifiprotect/const.py | 1 + .../components/unifiprotect/number.py | 33 +- tests/components/unifiprotect/test_number.py | 301 +++++++++++++++++- 3 files changed, 328 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 0b9cc4ab62d49e..e525635662df7a 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -89,6 +89,7 @@ Platform.CAMERA, Platform.EVENT, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 67af70c8bb2346..24dd0e7990a6b7 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -7,11 +7,16 @@ from typing import override from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel -from uiprotect.data.public_devices import PublicLight, SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -79,7 +84,9 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_min=1, ufp_max=100, ufp_step=1, - ufp_required_field="has_mic", + # The public setter refuses a camera whose only microphone is a + # hot-plugged module, so the gate is the built-in flag in both modes. + ufp_required_field="feature_flags.has_mic", ufp_public_value="mic_volume", ufp_set_method="set_mic_volume", ufp_perm=PermRequired.WRITE, @@ -285,28 +292,42 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities += _async_all_chime_ring_volume_entities(data, device) async_add_entities(entities) + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + async_add_entities( + async_all_device_entities( + data, + ProtectNumbers, + model_descriptions=_MODEL_DESCRIPTIONS, + public_device=device, + ) + ) + data.async_subscribe_adopt(_add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) + ) entities = async_all_device_entities( data, ProtectNumbers, model_descriptions=_MODEL_DESCRIPTIONS, ) - # Add ring volume entities for all chimes - entities += _async_all_chime_ring_volume_entities(data) + if not data.api.is_public_only: + # The ring volume per paired camera is a private-only chime setting. + entities += _async_all_chime_ring_volume_entities(data) async_add_entities(entities) class ProtectNumbers(ProtectDeviceEntity, NumberEntity): """A UniFi Protect Number Entity.""" - device: Camera | Light entity_description: ProtectNumberEntityDescription _state_attrs = ("_attr_available", "_attr_native_value") def __init__( self, data: ProtectData, - device: Camera | Light, + device: ProtectDeviceType, description: ProtectNumberEntityDescription, ) -> None: """Initialize the Number Entities.""" diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 6bf8c840bfe9bb..4e348eb6ed60e6 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -1,6 +1,9 @@ """Test the UniFi Protect number platform.""" +from collections.abc import Callable, Coroutine from datetime import timedelta +from functools import partial +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -11,9 +14,13 @@ IRLEDMode, Light, Permission, + ProtectAdoptableDeviceModel, RingSetting, Sensor, + WSAction, ) +from uiprotect.data.devices import Hotplug +from uiprotect.data.public_devices import PublicChime, SensorFeatureCapability from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN from homeassistant.components.unifiprotect.number import ( @@ -22,6 +29,7 @@ SENSE_NUMBERS, ProtectNumberEntityDescription, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, @@ -30,9 +38,10 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import patch_ufp_method +from .conftest import UNIFI_MAC from .utils import ( MockUFPFixture, adopt_devices, @@ -154,6 +163,26 @@ async def test_number_setup_camera_none( assert_entity_counts(hass, Platform.NUMBER, 0, 0) +async def test_number_no_mic_level_for_hot_plugged_mic( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """A camera whose only microphone is hot-plugged gets no microphone level. + + ``Camera.has_mic`` counts the hot-plugged module, but the public setter the + number writes through refuses such a camera, so the built-in flag gates it. + """ + camera.feature_flags.has_mic = False + camera.feature_flags.hotplug = Hotplug(audio=True) + assert camera.has_mic + + await init_entry(hass, ufp, [camera]) + + assert "mic_level" not in _number_keys(entity_registry, camera.mac) + + async def test_number_setup_camera_missing_attr( hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ) -> None: @@ -734,3 +763,273 @@ async def test_chime_ring_volume_unavailable_when_unpaired( state = hass.states.get(entity_id) assert state assert state.state == "unavailable" + + +def _number_keys(entity_registry: er.EntityRegistry, mac: str) -> set[str]: + """Return the description keys of the numbers registered for a device.""" + prefix = f"{mac}_" + return { + entry.unique_id.removeprefix(prefix) + for entry in entity_registry.entities.values() + if entry.domain == Platform.NUMBER and entry.unique_id.startswith(prefix) + } + + +def _make_streamless_public_camera(camera: Camera, **kwargs: Any) -> Mock: + """Build a public camera without RTSPS streams (snapshot-only).""" + public = make_public_camera(camera, **kwargs) + public.rtsps_streams = None + return public + + +@pytest.mark.parametrize( + ("fixture_name", "make", "key", "value", "setter", "present_keys", "absent_keys"), + [ + pytest.param( + "camera", + partial(_make_streamless_public_camera, mic_volume=42), + "mic_level", + "42", + "set_mic_volume", + set(), + {"wdr_value", "zoom_position", "chime_duration", "icr_lux"}, + id="camera", + ), + pytest.param( + "doorbell", + partial(_make_streamless_public_camera, mic_volume=42), + "mic_level", + "42", + "set_mic_volume", + set(), + {"system_sounds_volume", "doorbell_ring_volume", "chime_duration"}, + id="doorbell", + ), + pytest.param( + "light", + partial(make_public_light, pir_sensitivity=30), + "sensitivity", + "30", + "set_sensitivity", + {"duration"}, + set(), + id="light", + ), + pytest.param( + "sensor_all", + partial( + make_public_sensor, + motion_sensitivity=42, + capabilities={SensorFeatureCapability.MOTION}, + ), + "sensitivity", + "42", + "set_motion_sensitivity", + set(), + set(), + id="sensor", + ), + ], +) +async def test_public_only_number_end_to_end( + hass: HomeAssistant, + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + fixture_name: str, + make: Callable[[ProtectAdoptableDeviceModel], Mock], + key: str, + value: str, + setter: str, + present_keys: set[str], + absent_keys: set[str], +) -> None: + """A public-only entry builds the migrated numbers from the public object. + + Private-only numbers are absent, the device is registered from public + identity and a new value goes to the public setter. + """ + device = request.getfixturevalue(fixture_name) + public = make(device) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = public + + await setup_public_only() + + assert ufp_public_only.entry.state is ConfigEntryState.LOADED + keys = _number_keys(entity_registry, device.mac) + assert key in keys + assert present_keys <= keys + assert not keys & absent_keys + + entity_id = entity_registry.async_get_entity_id( + Platform.NUMBER, DOMAIN, f"{device.mac}_{key}" + ) + assert entity_id + assert hass.states.get(entity_id).state == value + + entry = entity_registry.async_get(entity_id) + assert entry + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.model == public.type + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, UNIFI_MAC), ufp_public_only.entry.entry_id + ) + assert nvr_device + assert device_entry.via_device_id == nvr_device.id + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 55}, blocking=True + ) + getattr(public, setter).assert_awaited_once_with(55.0) + + +async def test_public_only_number_light_duration_setter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + light: Light, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """The auto-shutoff duration is written as a timedelta to the public light.""" + public = make_public_light(light, pir_duration_ms=30000) + ufp_public_only.api.public_bootstrap.lights[light.id] = public + + await setup_public_only() + + entity_id = entity_registry.async_get_entity_id( + Platform.NUMBER, DOMAIN, f"{light.mac}_duration" + ) + assert entity_id + assert hass.states.get(entity_id).state == "30" + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 45}, blocking=True + ) + public.set_duration.assert_awaited_once_with(timedelta(seconds=45)) + + +async def test_public_only_number_chime_has_no_numbers( + hass: HomeAssistant, + chime: Chime, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Chime volumes are private-only settings, so a public chime yields nothing.""" + public = Mock(spec=PublicChime) + public.id = chime.id + public.mac = chime.mac + public.name = chime.name + public.model = chime.model + public.state = DeviceState.CONNECTED + ufp_public_only.api.public_bootstrap.chimes[chime.id] = public + + await setup_public_only() + + assert ufp_public_only.entry.state is ConfigEntryState.LOADED + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + + +def _make_public_camera_without_mic(camera: Camera) -> Mock: + """Build a public camera whose feature flags carry no built-in microphone.""" + public = _make_streamless_public_camera(camera) + public.feature_flags.has_mic = False + return public + + +@pytest.mark.parametrize( + ("fixture_name", "make"), + [ + pytest.param( + "camera", _make_public_camera_without_mic, id="camera_without_mic" + ), + pytest.param( + "sensor_all", + partial( + make_public_sensor, capabilities={SensorFeatureCapability.TEMPERATURE} + ), + id="sensor_without_motion", + ), + ], +) +async def test_public_only_number_gated_out( + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + fixture_name: str, + make: Callable[[ProtectAdoptableDeviceModel], Mock], +) -> None: + """A device failing the public gate gets no number. + + The camera gate is the built-in microphone flag, the sense gate the + motion capability. + """ + device = request.getfixturevalue(fixture_name) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = make(device) + + await setup_public_only() + + assert _number_keys(entity_registry, device.mac) == set() + + +async def test_public_only_number_added_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + light: Light, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + caplog: pytest.LogCaptureFixture, +) -> None: + """In public-only mode a light added later gets its numbers from its add frame. + + The public devices websocket ``add`` frame is the only discovery signal + without a local user; a re-delivered frame must not add a second time. + """ + await setup_public_only() + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + + public = make_public_light(light) + ufp_public_only.api.public_bootstrap.lights[light.id] = public + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert _number_keys(entity_registry, light.mac) == { + "sensitivity", + "duration", + } + count = len(hass.states.async_entity_ids(Platform.NUMBER.value)) + + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(Platform.NUMBER.value)) == count + assert "already exists" not in caplog.text + + +async def test_public_only_number_sense_registry_cleanup( + entity_registry: er.EntityRegistry, + sensor_all: Sensor, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """The capability cleanup runs without a private bootstrap.""" + stale = entity_registry.async_get_or_create( + Platform.NUMBER, + DOMAIN, + f"{sensor_all.mac}_sensitivity", + config_entry=ufp_public_only.entry, + ) + ufp_public_only.api.public_bootstrap.sensors[sensor_all.id] = make_public_sensor( + sensor_all, capabilities={SensorFeatureCapability.TEMPERATURE} + ) + + await setup_public_only() + + assert entity_registry.async_get(stale.entity_id) is None From 125bcf42e5d042e9df5afb2fc1b65e1301722937 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 20 Sep 2026 17:01:44 +0200 Subject: [PATCH 12/21] New: Threema Integration (#165993) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Norbert Rittel Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/threema/__init__.py | 58 ++ homeassistant/components/threema/client.py | 45 ++ .../components/threema/config_flow.py | 276 +++++++ homeassistant/components/threema/const.py | 8 + .../components/threema/manifest.json | 11 + homeassistant/components/threema/notify.py | 71 ++ .../components/threema/quality_scale.yaml | 100 +++ homeassistant/components/threema/strings.json | 85 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/threema/__init__.py | 1 + tests/components/threema/conftest.py | 97 +++ tests/components/threema/test_client.py | 64 ++ tests/components/threema/test_config_flow.py | 756 ++++++++++++++++++ tests/components/threema/test_init.py | 97 +++ tests/components/threema/test_notify.py | 245 ++++++ 20 files changed, 1937 insertions(+) create mode 100644 homeassistant/components/threema/__init__.py create mode 100644 homeassistant/components/threema/client.py create mode 100644 homeassistant/components/threema/config_flow.py create mode 100644 homeassistant/components/threema/const.py create mode 100644 homeassistant/components/threema/manifest.json create mode 100644 homeassistant/components/threema/notify.py create mode 100644 homeassistant/components/threema/quality_scale.yaml create mode 100644 homeassistant/components/threema/strings.json create mode 100644 tests/components/threema/__init__.py create mode 100644 tests/components/threema/conftest.py create mode 100644 tests/components/threema/test_client.py create mode 100644 tests/components/threema/test_config_flow.py create mode 100644 tests/components/threema/test_init.py create mode 100644 tests/components/threema/test_notify.py diff --git a/.strict-typing b/.strict-typing index d3213c0cd8e37a..dfa00bad85223e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -603,6 +603,7 @@ homeassistant.components.teltonika.* homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* +homeassistant.components.threema.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index 111b8e4c7120a1..51831978cadf1f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1910,6 +1910,8 @@ CLAUDE.md @home-assistant/core /tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core +/homeassistant/components/threema/ @LukasQ +/tests/components/threema/ @LukasQ /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py new file mode 100644 index 00000000000000..6c934eb44c8a88 --- /dev/null +++ b/homeassistant/components/threema/__init__.py @@ -0,0 +1,58 @@ +"""The Threema Gateway integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .client import ThreemaAPIClient, ThreemaAuthError, ThreemaConnectionError +from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.NOTIFY] + +type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Set up Threema Gateway from a config entry.""" + client = ThreemaAPIClient( + hass, + gateway_id=entry.data[CONF_GATEWAY_ID], + api_secret=entry.data[CONF_API_SECRET], + private_key=entry.data.get(CONF_PRIVATE_KEY), + ) + + try: + await client.validate_credentials() + except ThreemaAuthError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ThreemaConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: ThreemaConfigEntry +) -> None: + """Reload entry when config is updated (e.g. subentry added/removed).""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py new file mode 100644 index 00000000000000..bd4d5bbf73f0f2 --- /dev/null +++ b/homeassistant/components/threema/client.py @@ -0,0 +1,45 @@ +"""Threema Gateway API client, wired to Home Assistant's shared aiohttp session.""" + +from aiothreema import ( + ThreemaAuthError, + ThreemaConnectionError, + ThreemaGatewayClient, + ThreemaSendError, + derive_public_key, + generate_key_pair, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +__all__ = [ + "ThreemaAPIClient", + "ThreemaAuthError", + "ThreemaConnectionError", + "ThreemaSendError", + "derive_public_key", + "generate_key_pair", +] + + +class ThreemaAPIClient(ThreemaGatewayClient): + """Threema Gateway client bound to Home Assistant's shared aiohttp session. + + The Gateway HTTP protocol and end-to-end encryption live in the + `aiothreema` library; this class only wires it up to Home Assistant. + """ + + def __init__( + self, + hass: HomeAssistant, + gateway_id: str, + api_secret: str, + private_key: str | None = None, + ) -> None: + """Initialize the client with Home Assistant's shared session.""" + super().__init__( + gateway_id, + api_secret, + private_key, + session=async_get_clientsession(hass), + ) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py new file mode 100644 index 00000000000000..efe3f49d70ddca --- /dev/null +++ b/homeassistant/components/threema/config_flow.py @@ -0,0 +1,276 @@ +"""Config flow for Threema Gateway integration.""" + +import logging +import re +from typing import Any, override + +import probatio + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + derive_public_key, + generate_key_pair, +) +from .const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) + +_LOGGER = logging.getLogger(__name__) + +_KEY_HEX_LENGTH = 64 +_KEY_PREFIXES = ("private:", "public:") +_CONF_PUBLIC_KEY = "public_key" +_GATEWAY_ID_REGEX = re.compile(r"^\*[A-Z0-9]{7}$") + + +def _strip_key_prefix(value: str, expected_prefix: str) -> str | None: + """Strip the expected Threema key-export prefix, if present. + + Returns None if the value carries a *different* key-type prefix (e.g. + a 'public:' key pasted into the private-key field), so the mismatch + can be rejected instead of silently accepted as the wrong key type. + """ + lowered = value.lower() + for prefix in _KEY_PREFIXES: + if lowered.startswith(prefix): + if prefix != expected_prefix: + return None + return value[len(prefix) :].strip() + return value + + +def _is_valid_key_hex(value: str) -> bool: + """Return True if value is a 64-character hex string (32-byte NaCl key).""" + if len(value) != _KEY_HEX_LENGTH: + return False + try: + bytes.fromhex(value) + except ValueError: + return False + return True + + +class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Threema Gateway.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentry types supported by this integration.""" + return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} + + _gateway_id: str | None = None + _api_secret: str | None = None + _private_key: str | None = None + _public_key: str | None = None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - choose setup type.""" + return self.async_show_menu( + step_id="user", + menu_options=["credentials", "setup_new"], + ) + + async def async_step_setup_new( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Generate keys for a new Gateway ID.""" + if user_input is not None: + return await self.async_step_credentials() + + try: + private_key, public_key = await self.hass.async_add_executor_job( + generate_key_pair + ) + except Exception: + _LOGGER.exception("Failed to generate key pair") + return self.async_abort(reason="key_generation_failed") + + self._private_key = private_key + + return self.async_show_form( + step_id="setup_new", + description_placeholders={ + "public_key": public_key, + "private_key": private_key, + }, + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect Gateway credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + gateway_id = user_input[CONF_GATEWAY_ID].strip().upper() + + if not _GATEWAY_ID_REGEX.match(gateway_id): + errors["base"] = "invalid_gateway_id" + else: + await self.async_set_unique_id(gateway_id) + self._abort_if_unique_id_configured() + + self._gateway_id = gateway_id + self._api_secret = user_input[CONF_API_SECRET].strip() + + raw_private_key = user_input.get(CONF_PRIVATE_KEY, "").strip() + self._private_key = raw_private_key or None + raw_public_key = user_input.get(_CONF_PUBLIC_KEY, "").strip() + self._public_key = raw_public_key or None + + private_key = ( + _strip_key_prefix(raw_private_key, "private:") + if raw_private_key + else None + ) + public_key = ( + _strip_key_prefix(raw_public_key, "public:") + if raw_public_key + else None + ) + + if raw_private_key and private_key is None: + errors[CONF_PRIVATE_KEY] = "invalid_key" + elif raw_public_key and public_key is None: + errors[_CONF_PUBLIC_KEY] = "invalid_key" + elif private_key and not _is_valid_key_hex(private_key): + errors[CONF_PRIVATE_KEY] = "invalid_key" + elif public_key and not _is_valid_key_hex(public_key): + errors[_CONF_PUBLIC_KEY] = "invalid_key" + elif public_key and not private_key: + errors[_CONF_PUBLIC_KEY] = "public_key_requires_private_key" + elif ( + private_key + and public_key + and derive_public_key(private_key).lower() != public_key.lower() + ): + errors[_CONF_PUBLIC_KEY] = "key_mismatch" + else: + client = ThreemaAPIClient( + self.hass, + gateway_id=gateway_id, + api_secret=self._api_secret, + private_key=private_key, + ) + + try: + await client.validate_credentials() + except ThreemaAuthError: + errors["base"] = "invalid_auth" + except ThreemaConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating credentials") + errors["base"] = "unknown" + else: + data: dict[str, str] = { + CONF_GATEWAY_ID: self._gateway_id, + CONF_API_SECRET: self._api_secret, + } + if private_key: + data[CONF_PRIVATE_KEY] = private_key + + return self.async_create_entry( + title=f"Threema {self._gateway_id}", + data=data, + ) + + schema = probatio.Schema( + { + probatio.Required(CONF_GATEWAY_ID, default=self._gateway_id or ""): str, + probatio.Required( + CONF_API_SECRET, default=self._api_secret or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + probatio.Optional( + CONF_PRIVATE_KEY, default=self._private_key or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + probatio.Optional( + _CONF_PUBLIC_KEY, default=self._public_key or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), + } + ) + + return self.async_show_form( + step_id="credentials", + data_schema=schema, + errors=errors, + ) + + +_RECIPIENT_ID_REGEX = re.compile(r"^[0-9A-Za-z]{8}$") + + +class RecipientSubentryFlowHandler(ConfigSubentryFlow): + """Handle adding a Threema recipient as a subentry.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the recipient subentry step.""" + errors: dict[str, str] = {} + + if user_input is not None: + recipient_id = user_input[CONF_RECIPIENT].strip().upper() + + if not _RECIPIENT_ID_REGEX.match(recipient_id): + errors[CONF_RECIPIENT] = "invalid_recipient_id" + else: + # Check for duplicate recipients + for subentry in self._get_entry().subentries.values(): + if subentry.data.get(CONF_RECIPIENT) == recipient_id: + return self.async_abort(reason="already_configured") + + raw_name = user_input.get(CONF_NAME, "").strip() + title = f"{raw_name} ({recipient_id})" if raw_name else recipient_id + + data: dict[str, str] = {CONF_RECIPIENT: recipient_id} + if raw_name: + data[CONF_NAME] = raw_name + + return self.async_create_entry( + title=title, + data=data, + unique_id=recipient_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=probatio.Schema( + { + probatio.Required(CONF_RECIPIENT): str, + probatio.Optional(CONF_NAME): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/threema/const.py b/homeassistant/components/threema/const.py new file mode 100644 index 00000000000000..7fa164df712044 --- /dev/null +++ b/homeassistant/components/threema/const.py @@ -0,0 +1,8 @@ +"""Constants for the Threema Gateway integration.""" + +DOMAIN = "threema" + +CONF_GATEWAY_ID = "gateway_id" +CONF_API_SECRET = "api_secret" +CONF_PRIVATE_KEY = "private_key" +SUBENTRY_TYPE_RECIPIENT = "recipient" diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json new file mode 100644 index 00000000000000..06c3fabdabe777 --- /dev/null +++ b/homeassistant/components/threema/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "threema", + "name": "Threema", + "codeowners": ["@LukasQ"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/threema", + "integration_type": "service", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["aiothreema==0.1.0"] +} diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py new file mode 100644 index 00000000000000..6fd24a3e5793b2 --- /dev/null +++ b/homeassistant/components/threema/notify.py @@ -0,0 +1,71 @@ +"""Notify platform for Threema Gateway integration.""" + +from typing import override + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThreemaConfigEntry +from .client import ThreemaAuthError, ThreemaConnectionError, ThreemaSendError +from .const import DOMAIN, SUBENTRY_TYPE_RECIPIENT + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThreemaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Threema notify entities from config entry subentries.""" + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_RECIPIENT): + async_add_entities( + [ThreemaNotifyEntity(entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class ThreemaNotifyEntity(NotifyEntity): + """Notify entity for sending messages to a Threema recipient.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + entry: ThreemaConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the notify entity.""" + self._client = entry.runtime_data + self._recipient_id: str = subentry.data[CONF_RECIPIENT] + + self._attr_unique_id = f"{self._client.gateway_id}_{self._recipient_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="Threema", + identifiers={(DOMAIN, self._attr_unique_id)}, + name=subentry.title, + ) + + @override + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to the configured Threema recipient.""" + text = f"*{title}*\n{message}" if title else message + try: + await self._client.send_text_message(self._recipient_id, text) + except ThreemaAuthError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except (ThreemaSendError, ThreemaConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml new file mode 100644 index 00000000000000..d1ee16ff5caa37 --- /dev/null +++ b/homeassistant/components/threema/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: Integration only sends messages, no polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: Integration does not provide any automation conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not provide any automation triggers. + entity-event-setup: + status: exempt + comment: Integration does not use event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Notify entities are stateless send-only, availability is not applicable. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Notify entities are stateless send-only. + parallel-updates: + status: exempt + comment: No polling or data updates. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: Cloud service, not discoverable on the network. + discovery-update-info: + status: exempt + comment: Cloud service, not discoverable on the network. + docs-data-update: + status: exempt + comment: Integration does not poll or update data. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Service integration, no physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices in current scope. + entity-category: + status: exempt + comment: Notify entities have no applicable category. + entity-device-class: + status: exempt + comment: No applicable device class for notify entities. + entity-disabled-by-default: + status: exempt + comment: Notify entities should be enabled by default for immediate use. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Notify entities use default platform icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No scenarios requiring repair issues. + stale-devices: + status: exempt + comment: No devices in current scope. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json new file mode 100644 index 00000000000000..0fe86935542004 --- /dev/null +++ b/homeassistant/components/threema/strings.json @@ -0,0 +1,85 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "key_generation_failed": "Failed to generate encryption keys." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_gateway_id": "Gateway ID must start with * followed by 7 alphanumeric characters.", + "invalid_key": "Key must be a 64-character hex string (32 bytes).", + "key_mismatch": "The public key does not match the private key.", + "public_key_requires_private_key": "Enter the private key too to verify it against the public key.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "credentials": { + "data": { + "api_secret": "API secret", + "gateway_id": "Gateway ID", + "private_key": "Private key (optional, enables E2E encryption)", + "public_key": "Public key to verify against (optional)" + }, + "data_description": { + "api_secret": "Secret you received from Threema", + "gateway_id": "ID of your Gateway, including '*', e.g.: *ABCD123", + "private_key": "Private key as hex string (64 characters). A 'private:' prefix, as used by Threema's key export tools, is accepted and stripped automatically. Leave empty for simple (non-E2E) mode.", + "public_key": "Never stored — only checked once against the private key above to catch copy-paste mistakes. Paste the public key you registered at gateway.threema.ch (a 'public:' prefix is accepted)." + }, + "description": "Enter your Threema Gateway ID and secret obtained from gateway.threema.ch. Threema enables end-to-end mode for a Gateway ID manually after you register a public key, which can take a few days — so you may well be completing this step long after the keys were generated.", + "title": "Gateway credentials" + }, + "setup_new": { + "description": "Your keys have been generated. Save them now before continuing!\n\nPublic key: `{public_key}`\nPrivate key: `{private_key}`\n\nWhen registering at gateway.threema.ch, paste the public key as shown above. Store the private key securely — it cannot be recovered.", + "title": "Keys generated" + }, + "user": { + "menu_options": { + "credentials": "Add existing Gateway ID", + "setup_new": "Generate new encryption keys" + }, + "title": "Threema setup" + } + } + }, + "config_subentries": { + "recipient": { + "abort": { + "already_configured": "This recipient is already configured." + }, + "entry_type": "Recipient", + "error": { + "invalid_recipient_id": "Threema ID must be exactly 8 alphanumeric characters." + }, + "initiate_flow": { + "user": "Add recipient" + }, + "step": { + "user": { + "data": { + "name": "Display name", + "recipient": "Threema ID" + }, + "data_description": { + "name": "Optional friendly name for this recipient (e.g. 'Dad').", + "recipient": "The 8-character Threema ID of the recipient." + }, + "description": "Enter the Threema ID of the person you want to send messages to.", + "title": "Add recipient" + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "send_error": { + "message": "Error sending message: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b09efe44e6b180..02320c4611654b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -810,6 +810,7 @@ "thermopro", "thethingsnetwork", "thread", + "threema", "tibber", "tile", "tilt_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 62262e0f421abb..de86b897ecda64 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7504,6 +7504,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "threema": { + "name": "Threema", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push" + }, "tibber": { "name": "Tibber", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 489d9261dafe19..c7660967c1d575 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5790,6 +5790,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threema.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5a22f56c5a5a40..68d1fc739e1cfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,6 +470,9 @@ aiotankerkoenig==0.5.3 # homeassistant.components.tedee aiotedee==0.3.0 +# homeassistant.components.threema +aiothreema==0.1.0 + # homeassistant.components.tractive aiotractive==1.0.3 diff --git a/tests/components/threema/__init__.py b/tests/components/threema/__init__.py new file mode 100644 index 00000000000000..a7bc4a6805f0ad --- /dev/null +++ b/tests/components/threema/__init__.py @@ -0,0 +1 @@ +"""Tests for the Threema Gateway integration.""" diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py new file mode 100644 index 00000000000000..3b97f71a16e3d2 --- /dev/null +++ b/tests/components/threema/conftest.py @@ -0,0 +1,97 @@ +"""Fixtures for Threema Gateway integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_RECIPIENT + +from tests.common import MockConfigEntry + +MOCK_GATEWAY_ID = "*TESTGWY" +MOCK_API_SECRET = "test_secret_key_12345" +MOCK_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +MOCK_PUBLIC_KEY = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +MOCK_RECIPIENT_ID = "ABCD1234" +MOCK_SUBENTRY_ID = "mock_subentry_id" + +RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, +} + + +@pytest.fixture +def mock_subentries() -> list[ConfigSubentryDataWithId]: + """Fixture providing one recipient subentry by default; override to [] when not needed.""" + return [RECIPIENT_SUBENTRY] + + +@pytest.fixture +def mock_config_entry( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for basic mode (no encryption).""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_config_entry_with_keys( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for E2E encrypted mode.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_credentials() -> Generator[AsyncMock]: + """Mock ThreemaAPIClient.validate_credentials to succeed by default.""" + with patch( + "homeassistant.components.threema.client.ThreemaAPIClient.validate_credentials", + new_callable=AsyncMock, + ) as mock: + yield mock + + +@pytest.fixture +def mock_send_message() -> Generator[AsyncMock]: + """Mock ThreemaAPIClient.send_text_message to return a message ID.""" + with patch( + "homeassistant.components.threema.client.ThreemaAPIClient.send_text_message", + new_callable=AsyncMock, + return_value="mock_message_id", + ) as mock: + yield mock diff --git a/tests/components/threema/test_client.py b/tests/components/threema/test_client.py new file mode 100644 index 00000000000000..3d7e39486bcf36 --- /dev/null +++ b/tests/components/threema/test_client.py @@ -0,0 +1,64 @@ +"""Unit tests for the Threema Gateway API client wrapper. + +`ThreemaAPIClient` only wires Home Assistant's shared aiohttp session into +`aiothreema.ThreemaGatewayClient`. The Gateway HTTP protocol, encryption, +and key generation are implemented and tested in that library itself, so +this file only tests the wiring this integration owns. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from aiothreema import ThreemaGatewayClient + +from homeassistant.components.threema.client import ThreemaAPIClient +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_PRIVATE_KEY + + +def _patch_session(session: MagicMock): + """Return a context manager that patches async_get_clientsession.""" + return patch( + "homeassistant.components.threema.client.async_get_clientsession", + return_value=session, + ) + + +def test_is_a_threema_gateway_client(hass: HomeAssistant) -> None: + """Test the wrapper is usable wherever a ThreemaGatewayClient is expected.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + assert isinstance(client, ThreemaGatewayClient) + + +def test_uses_home_assistants_shared_session(hass: HomeAssistant) -> None: + """Test the client is wired to Home Assistant's shared aiohttp session.""" + mock_session = MagicMock() + with patch( + "homeassistant.components.threema.client.async_get_clientsession", + return_value=mock_session, + ) as mock_get_session: + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + + mock_get_session.assert_called_once_with(hass) + assert client._session is mock_session + + +def test_passes_through_credentials(hass: HomeAssistant) -> None: + """Test gateway id, API secret, and private key are passed through as-is.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient( + hass, MOCK_GATEWAY_ID, MOCK_API_SECRET, private_key=MOCK_PRIVATE_KEY + ) + assert client.gateway_id == MOCK_GATEWAY_ID + assert client.api_secret == MOCK_API_SECRET + assert client.private_key == MOCK_PRIVATE_KEY + + +def test_private_key_defaults_to_none(hass: HomeAssistant) -> None: + """Test private_key defaults to None for simple (non-E2E) mode.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + assert client.private_key is None diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py new file mode 100644 index 00000000000000..0d8e74d95cd1ed --- /dev/null +++ b/tests/components/threema/test_config_flow.py @@ -0,0 +1,756 @@ +"""Test the Threema Gateway config flow.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import probatio +import pytest + +from homeassistant import config_entries +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, + derive_public_key, +) +from homeassistant.components.threema.config_flow import _CONF_PUBLIC_KEY +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[None]: + """Patch async_setup_entry to avoid full setup during flow tests.""" + with patch("homeassistant.components.threema.async_setup_entry", return_value=True): + yield + + +async def test_user_flow_existing_gateway( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with existing gateway credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Threema {MOCK_GATEWAY_ID}" + assert result["data"] == { + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + } + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_existing_with_keys( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with existing gateway including optional private key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"][CONF_PRIVATE_KEY] + == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_new_gateway( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with new gateway (key generation).""" + generated_private_key = "0" * 64 + generated_public_key = "f" * 64 + + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + return_value=(generated_private_key, generated_public_key), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "setup_new" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: generated_private_key, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == generated_private_key + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: + """Test user flow aborts when key generation fails.""" + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + side_effect=RuntimeError("Key generation failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "key_generation_failed" + + +async def test_credentials_invalid_gateway_id( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test credentials step with invalid Gateway ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + # Gateway ID not starting with * + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "TESTGWY1", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Gateway ID wrong length + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*TEST", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Right length and prefix, but invalid characters + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*!!!!!!!", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Valid Gateway ID — recover and create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +@pytest.mark.parametrize( + "invalid_key", + ["0123456789abcdef", "g" * 64], + ids=["wrong_length", "non_hex"], +) +async def test_credentials_invalid_private_key( + hass: HomeAssistant, + mock_credentials: AsyncMock, + invalid_key: str, +) -> None: + """Test credentials step rejects a malformed private key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: invalid_key, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + # Recover by clearing the invalid key (the field defaults to the + # previous, invalid value, so it must be explicitly cleared) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + "prefixed_key", + [ + "private:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "PRIVATE:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ], + ids=["lower", "upper"], +) +async def test_credentials_private_key_prefix_stripped( + hass: HomeAssistant, + mock_credentials: AsyncMock, + prefixed_key: str, +) -> None: + """Test a 'private:'-prefixed key (as exported by Threema's tools) is accepted.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: prefixed_key, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"][CONF_PRIVATE_KEY] + == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + + +async def test_credentials_public_key_in_private_key_field_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a 'public:'-prefixed key pasted into the private-key field is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: f"public:{'a' * 64}", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_private_key_in_public_key_field_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a 'private:'-prefixed key pasted into the public-key field is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + _CONF_PUBLIC_KEY: f"private:{'b' * 64}", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_without_private_key_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key given alone (no private key) is rejected, not discarded.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + _CONF_PUBLIC_KEY: "a" * 64, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "public_key_requires_private_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_matches( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key matching the private key is accepted and not stored.""" + private_key = "1" * 64 + matching_public_key = derive_public_key(private_key) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: private_key, + _CONF_PUBLIC_KEY: f"public:{matching_public_key}", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == private_key + assert _CONF_PUBLIC_KEY not in result["data"] + + +async def test_credentials_public_key_mismatch( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key that does not match the private key is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "f" * 64, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "key_mismatch"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_invalid_hex( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a malformed public key is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "not-a-valid-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_invalid_private_key_preserves_other_fields( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test gateway ID and API secret stay filled in after an invalid key error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "not-a-valid-key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + defaults = { + schema_key: schema_key.default() + for schema_key in result["data_schema"].schema + if schema_key.default is not probatio.UNDEFINED + } + assert defaults[CONF_GATEWAY_ID] == MOCK_GATEWAY_ID + assert defaults[CONF_API_SECRET] == MOCK_API_SECRET + + +async def test_credentials_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test credentials step when gateway is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ThreemaConnectionError("Connection refused"), "cannot_connect"), + (ThreemaConnectionError("Server error"), "cannot_connect"), + (ThreemaAuthError("Invalid credentials"), "invalid_auth"), + (RuntimeError("Unexpected"), "unknown"), + ], + ids=["cannot_connect", "server_error_non_auth", "invalid_auth", "unknown_error"], +) +async def test_credentials_error( + hass: HomeAssistant, + mock_credentials: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test credentials step with various errors.""" + mock_credentials.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_credentials.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_subentry_add_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test adding a recipient via subentry flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "EFGH5678" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" + + +async def test_subentry_add_recipient_with_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test adding a recipient with a display name.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678", "name": "Dad"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Dad (EFGH5678)" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678", CONF_NAME: "Dad"} + assert result["unique_id"] == "EFGH5678" + + +@pytest.mark.parametrize( + "invalid_id", + ["ABC", "ABCD!@#$", ""], + ids=["too_short", "special_chars", "empty"], +) +async def test_subentry_invalid_recipient_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + invalid_id: str, +) -> None: + """Test subentry flow rejects invalid Threema ID with an inline form error.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: invalid_id}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_RECIPIENT: "invalid_recipient_id"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_subentry_duplicate_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test subentry flow rejects duplicate recipient.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py new file mode 100644 index 00000000000000..c6fab5551d8da1 --- /dev/null +++ b/tests/components/threema/test_init.py @@ -0,0 +1,97 @@ +"""Test the Threema Gateway integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (ThreemaConnectionError("Connection refused"), ConfigEntryState.SETUP_RETRY), + (ThreemaAuthError("Invalid credentials"), ConfigEntryState.SETUP_ERROR), + (ThreemaConnectionError("Server error"), ConfigEntryState.SETUP_RETRY), + ], + ids=["connection_error", "auth_error", "server_error_non_auth"], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles various errors correctly.""" + mock_config_entry.add_to_hass(hass) + mock_credentials.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_update_listener_reloads( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test that update listener reloads the entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_reload: + mock_reload.return_value = None + hass.config_entries.async_update_entry(mock_config_entry, title="Updated") + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(mock_config_entry.entry_id) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py new file mode 100644 index 00000000000000..e501485e322c47 --- /dev/null +++ b/tests/components/threema/test_notify.py @@ -0,0 +1,245 @@ +"""Test the Threema Gateway notify platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, + ThreemaSendError, +) +from homeassistant.components.threema.const import SUBENTRY_TYPE_RECIPIENT +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID, RECIPIENT_SUBENTRY + +from tests.common import MockConfigEntry + +_SECOND_RECIPIENT_ID = "WXYZ9999" +_SECOND_RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: _SECOND_RECIPIENT_ID}, + "subentry_id": "second_recipient_subentry_id", + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": "Second recipient", + "unique_id": _SECOND_RECIPIENT_ID, +} + + +async def test_notify_entity_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test notify entity is created from subentry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" + + +@pytest.mark.parametrize( + "mock_subentries", [[RECIPIENT_SUBENTRY, _SECOND_RECIPIENT_SUBENTRY]] +) +async def test_notify_entities_get_separate_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test each recipient gets its own device, not a shared one. + + A device can only belong to a single config subentry; sharing one + across recipient subentries makes Home Assistant silently reassign + it whenever a new recipient is added, orphaning the previous one. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 2 + + device_ids = {e.device_id for e in notify_entities} + assert len(device_ids) == 2 + for device_id in device_ids: + assert device_id is not None + device = device_registry.async_get(device_id) + assert device is not None + assert device.config_entry_id == mock_config_entry.entry_id + assert device.config_subentry_id is not None + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_notify_entity_not_created_without_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 + + +async def test_send_message_simple( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (simple mode).""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello from tests!"}, + blocking=True, + ) + + mock_send_message.assert_called_once_with(MOCK_RECIPIENT_ID, "Hello from tests!") + + +async def test_send_message_with_title( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test a title is formatted as a leading bold line in the sent text.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello from tests!", + "title": "My Title", + }, + blocking=True, + ) + + mock_send_message.assert_called_once_with( + MOCK_RECIPIENT_ID, "*My Title*\nHello from tests!" + ) + + +async def test_send_message_e2e( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (E2E mode).""" + mock_config_entry_with_keys.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello E2E!"}, + blocking=True, + ) + + mock_send_message.assert_called_once_with(MOCK_RECIPIENT_ID, "Hello E2E!") + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ThreemaSendError("Send failed"), "Error sending message: Send failed"), + ( + ThreemaConnectionError("Connection error"), + "Error sending message: Connection error", + ), + (ThreemaAuthError("Invalid credentials"), "Invalid authentication"), + ], + ids=["send_error", "connection_error", "auth_error"], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, + side_effect: Exception, + expected_error: str, +) -> None: + """Test notify entity raises HomeAssistantError on send/connection errors.""" + mock_send_message.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError, match=expected_error): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + ) From 20cadab48819cbce23b0c9e467779b7c003b6d8d Mon Sep 17 00:00:00 2001 From: Michael Dluhosch <69163449+mdluhosch@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:04:13 +0200 Subject: [PATCH 13/21] Add a new integration for Theben Conexa smartmeter gateway (#174407) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/theben_conexa/__init__.py | 31 +++ .../components/theben_conexa/config_flow.py | 74 ++++++ .../components/theben_conexa/const.py | 9 + .../components/theben_conexa/coordinator.py | 96 ++++++++ .../components/theben_conexa/entity.py | 39 ++++ .../components/theben_conexa/icons.json | 12 + .../components/theben_conexa/manifest.json | 11 + .../theben_conexa/quality_scale.yaml | 78 +++++++ .../components/theben_conexa/sensor.py | 89 +++++++ .../components/theben_conexa/strings.json | 37 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/theben_conexa/__init__.py | 1 + tests/components/theben_conexa/conftest.py | 98 ++++++++ .../theben_conexa/snapshots/test_sensor.ambr | 117 ++++++++++ .../theben_conexa/test_config_flow.py | 220 ++++++++++++++++++ tests/components/theben_conexa/test_init.py | 71 ++++++ tests/components/theben_conexa/test_sensor.py | 48 ++++ 20 files changed, 1043 insertions(+) create mode 100644 homeassistant/components/theben_conexa/__init__.py create mode 100644 homeassistant/components/theben_conexa/config_flow.py create mode 100644 homeassistant/components/theben_conexa/const.py create mode 100644 homeassistant/components/theben_conexa/coordinator.py create mode 100644 homeassistant/components/theben_conexa/entity.py create mode 100644 homeassistant/components/theben_conexa/icons.json create mode 100644 homeassistant/components/theben_conexa/manifest.json create mode 100644 homeassistant/components/theben_conexa/quality_scale.yaml create mode 100755 homeassistant/components/theben_conexa/sensor.py create mode 100644 homeassistant/components/theben_conexa/strings.json create mode 100644 tests/components/theben_conexa/__init__.py create mode 100644 tests/components/theben_conexa/conftest.py create mode 100644 tests/components/theben_conexa/snapshots/test_sensor.ambr create mode 100644 tests/components/theben_conexa/test_config_flow.py create mode 100644 tests/components/theben_conexa/test_init.py create mode 100644 tests/components/theben_conexa/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 51831978cadf1f..8793d9bd24b5b2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1902,6 +1902,8 @@ CLAUDE.md @home-assistant/core /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core +/homeassistant/components/theben_conexa/ @mdluhosch +/tests/components/theben_conexa/ @mdluhosch /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss diff --git a/homeassistant/components/theben_conexa/__init__.py b/homeassistant/components/theben_conexa/__init__.py new file mode 100644 index 00000000000000..d53f20729113f5 --- /dev/null +++ b/homeassistant/components/theben_conexa/__init__.py @@ -0,0 +1,31 @@ +"""The Theben Conexa Smartmeter gateway integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SmgwSensorCoordinator, ThebenConfigEntry + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ThebenConfigEntry) -> bool: + """Set up Theben Conexa Smartmeter gateway from a config entry.""" + + coordinator = SmgwSensorCoordinator(hass, entry) + await coordinator.async_init() + entry.runtime_data = coordinator + + # first_refresh means get initial data + await coordinator.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ThebenConfigEntry) -> bool: + """Unload a config entry. + + The Conexa http based query protocol does not need any cleanup + """ + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/theben_conexa/config_flow.py b/homeassistant/components/theben_conexa/config_flow.py new file mode 100644 index 00000000000000..1fc2f35b962ddc --- /dev/null +++ b/homeassistant/components/theben_conexa/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Theben Conexa Smartmeter gateway integration.""" + +import logging +from typing import Any, override + +import aiohttp +from probatio import Required, Schema +from theben_conexa_smgw import ConexaSMGW, checkNetworkConnection + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = Schema( + { + Required(CONF_HOST, description={"suggested_value": "192.168.1.200"}): str, + Required(CONF_USERNAME): str, + Required(CONF_PASSWORD): str, + } +) + + +class ThebenConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Theben Conexa Smartmeter gateway.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + await checkNetworkConnection(user_input[CONF_HOST]) + except OSError, aiohttp.ClientError: + errors["base"] = "cannot_connect" + else: + try: + local_api = await ConexaSMGW.create( + async_get_clientsession(self.hass), + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + # According to the docs the grid operator can assign multiple users for the + # same gateway. So we use the combination of smgwID and username as unique id + await self.async_set_unique_id( + f"{local_api.gatewayInfo.smgwID}-{user_input[CONF_USERNAME]}" + ) + except OSError, aiohttp.ClientError: + # The smgw unfortunately does not reply with invalid auth it just times out + # So after we checked that connection is possible we assume Invalid auth if something happens + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Smartmeter Gateway", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/theben_conexa/const.py b/homeassistant/components/theben_conexa/const.py new file mode 100644 index 00000000000000..57ac4db09f664a --- /dev/null +++ b/homeassistant/components/theben_conexa/const.py @@ -0,0 +1,9 @@ +"""Constants for the Theben Conexa Smartmeter gateway integration.""" + +DOMAIN = "theben_conexa" + +# The pypi package theben_conexa_smgw returns the raw OBIS codes +# according to the EN IEC 62056-61:2024 standard as a string of 12 hex digits. +# The more human readable A-B:C.D.E*F could be extracted from the hex string which is AABBCCDDEEFF +OBIS_IN = "0100010800ff" +OBIS_OUT = "0100020800ff" diff --git a/homeassistant/components/theben_conexa/coordinator.py b/homeassistant/components/theben_conexa/coordinator.py new file mode 100644 index 00000000000000..2e41a255ea7202 --- /dev/null +++ b/homeassistant/components/theben_conexa/coordinator.py @@ -0,0 +1,96 @@ +"""Coordinator for the Theben Conexa Smartmeter gateway integration.""" + +from datetime import datetime +import logging +from typing import override + +import aiohttp +from theben_conexa_smgw import ConexaSMGW, checkNetworkConnection + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_utc_time_change +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +type ThebenConfigEntry = ConfigEntry[SmgwSensorCoordinator] + + +class SmgwSensorCoordinator(DataUpdateCoordinator[dict[str, ConexaSMGW.MeterValue]]): + """The data update coordinator for the Theben Conexa Smartmeter gateway integration.""" + + _api: ConexaSMGW + gateway_info: ConexaSMGW.GatewayInfo + config_entry: ThebenConfigEntry + smgw_user: str + + def __init__(self, hass: HomeAssistant, entry: ThebenConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Theben Conexa Local Poll", + config_entry=entry, + # Set to None so HA doesn't poll on a standard rolling interval + update_interval=None, + always_update=False, + ) + self._scheduled_updates: CALLBACK_TYPE | None = None + self.smgw_user = entry.data[CONF_USERNAME] + + async def async_init(self) -> None: + """Asynchronous Initialization and registering the update schedule.""" + + try: + await checkNetworkConnection(self.config_entry.data[CONF_HOST]) + except (TimeoutError, ConnectionRefusedError, OSError) as e: + raise ConfigEntryNotReady("Device is not reachable") from e + + # Unfortunately the Conexa 3.0 doesn't provide separate authentication feedback it just ignores + # all requests with invalid username/password, That's why here we need to assume it failed + # because of wrong credentials, as we checked for connectivity just before and the device was reachable. + try: + self._api = await ConexaSMGW.create( + async_get_clientsession(self.hass), + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except (TimeoutError, aiohttp.ClientError) as e: + raise ConfigEntryAuthFailed("Authentication failed") from e + + self.gateway_info = self._api.gatewayInfo + + # Currently the SMGW provides new data only every 15 minutes at the starting of the hour (in UTC). + # So we leverage this information to set up a scheduled poll at + # exactly these times + some seconds to allow for processing. + self._scheduled_updates = async_track_utc_time_change( + self.hass, + self._scheduled_update, + minute=[0, 15, 30, 45], + second=40, + ) + + @override + async def _async_update_data(self) -> dict[str, ConexaSMGW.MeterValue]: + """Fetch data from API endpoint.""" + + return await self._api.getLatestValues() + + async def _scheduled_update(self, now: datetime) -> None: + """Triggered exactly at the time pattern specified in async_init.""" + _LOGGER.debug("Starting scheduled poll at %s", now) + await self.async_refresh() + + @override + async def async_shutdown(self) -> None: + """Cancel any updates before shutting down.""" + if self._scheduled_updates: + self._scheduled_updates() + self._scheduled_updates = None + + await super().async_shutdown() diff --git a/homeassistant/components/theben_conexa/entity.py b/homeassistant/components/theben_conexa/entity.py new file mode 100644 index 00000000000000..7f0bd2ba0e998b --- /dev/null +++ b/homeassistant/components/theben_conexa/entity.py @@ -0,0 +1,39 @@ +"""Base Entity for the Theben Conexa Smartmeter gateway integration.""" + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SmgwSensorCoordinator + + +class ConexaSMGWEntity(CoordinatorEntity[SmgwSensorCoordinator]): + """Defines a base Theben Conexa Smartmeter gateway entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SmgwSensorCoordinator) -> None: + """Initialize the Base entity.""" + super().__init__(coordinator) + raw_serial = coordinator.gateway_info.smgwID.upper() + # For example convert ETHE0213456789 to E THE02 1345 6789: + # First char (device type like E for electricity) + # A block of 3 chars THE (for theben) and 2 digits for production batch + # two blocks of 4 digits to make it unique + formatted_serial = " ".join( + [ + raw_serial[:1], + raw_serial[1:6], + *(raw_serial[i : i + 4] for i in range(6, len(raw_serial), 4)), + ] + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.gateway_info.smgwID)}, + manufacturer="Theben AG", + model="CONEXA 3.0 Smart Meter Gateway", + sw_version=coordinator.gateway_info.firmwareVersion, + serial_number=formatted_serial, + configuration_url=f"https://{coordinator.config_entry.data[CONF_HOST]}", + ) diff --git a/homeassistant/components/theben_conexa/icons.json b/homeassistant/components/theben_conexa/icons.json new file mode 100644 index 00000000000000..86279c2eff2a73 --- /dev/null +++ b/homeassistant/components/theben_conexa/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "energy_consumed": { + "default": "mdi:home-import-outline" + }, + "energy_supplied": { + "default": "mdi:home-export-outline" + } + } + } +} diff --git a/homeassistant/components/theben_conexa/manifest.json b/homeassistant/components/theben_conexa/manifest.json new file mode 100644 index 00000000000000..b798a3d11d77d6 --- /dev/null +++ b/homeassistant/components/theben_conexa/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "theben_conexa", + "name": "Theben Conexa Smart Meter Gateway", + "codeowners": ["@mdluhosch"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/theben_conexa", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["theben-conexa-smgw==0.4.2"] +} diff --git a/homeassistant/components/theben_conexa/quality_scale.yaml b/homeassistant/components/theben_conexa/quality_scale.yaml new file mode 100644 index 00000000000000..3eefb60bff690c --- /dev/null +++ b/homeassistant/components/theben_conexa/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: "Integration doesn't provide service actions" + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: "Integration doesn't provide service actions" + docs-triggers: + status: exempt + comment: "Integration doesn't provide triggers" + docs-conditions: + status: exempt + comment: "Integration doesn't provide conditions" + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: "Integration doesn't register events" + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: "Integration doesn't provide service actions" + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: + status: exempt + comment: "Conexa SMGW currently gets assigned a static IP by the power grid operator. Hence the common discovery mechanisms are not applicable." + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: "Impossible as every device has its own set of IP/User/Password" + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/theben_conexa/sensor.py b/homeassistant/components/theben_conexa/sensor.py new file mode 100755 index 00000000000000..69377c6e0dd892 --- /dev/null +++ b/homeassistant/components/theben_conexa/sensor.py @@ -0,0 +1,89 @@ +"""Sensor for the Theben Conexa Smartmeter gateway integration.""" + +import logging +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OBIS_IN, OBIS_OUT +from .coordinator import SmgwSensorCoordinator, ThebenConfigEntry +from .entity import ConexaSMGWEntity + +_LOGGER = logging.getLogger(__name__) + +# So far the Conexa 3.0 provides only total active energy in and out. +KNOWN_OBIS_CODES: dict[str, SensorEntityDescription] = { + OBIS_IN: SensorEntityDescription( + key=OBIS_IN, + translation_key="energy_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + OBIS_OUT: SensorEntityDescription( + key=OBIS_OUT, + translation_key="energy_supplied", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThebenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + sensors: list[TotalInOutSensor] = [] + + for obis_code in entry.runtime_data.data: + if obis_code in KNOWN_OBIS_CODES: + sensors.append( + TotalInOutSensor( + description=KNOWN_OBIS_CODES[obis_code], + coordinator=entry.runtime_data, + ) + ) + else: + _LOGGER.warning( + "Skipping unsupported Conexa SMGW key %s during setup", obis_code + ) + + async_add_entities(sensors) + + +class TotalInOutSensor(ConexaSMGWEntity, SensorEntity): + """Represents total Meter readings.""" + + def __init__( + self, + description: SensorEntityDescription, + coordinator: SmgwSensorCoordinator, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._key = description.key + # As far as I know the Conexa 3.0 returns Wh but there is the possibility that it returns Joules + if coordinator.data[self._key].unit.upper() == "J": + self._attr_native_unit_of_measurement = UnitOfEnergy.JOULE + self._attr_unique_id = ( + f"{coordinator.gateway_info.smgwID}-{coordinator.smgw_user}-{self._key}" + ) + + @property + @override + def native_value(self) -> str: + """Return the current sensor value.""" + return self.coordinator.data[self._key].value diff --git a/homeassistant/components/theben_conexa/strings.json b/homeassistant/components/theben_conexa/strings.json new file mode 100644 index 00000000000000..8b14d98daddec9 --- /dev/null +++ b/homeassistant/components/theben_conexa/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The IP of your smartmeter gateway provided by your power grid operator", + "password": "The password provided by your power grid operator", + "username": "The username provided by your power grid operator" + }, + "description": "As a prerequisite you must be able to log in to your smartmeter gateway from a web browser via `https://[IP of your gateway]`." + } + } + }, + "entity": { + "sensor": { + "energy_consumed": { + "name": "Energy consumed" + }, + "energy_supplied": { + "name": "Energy supplied" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 02320c4611654b..615013b29feca2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -806,6 +806,7 @@ "tesla_wall_connector", "teslemetry", "tessie", + "theben_conexa", "thermobeacon", "thermopro", "thethingsnetwork", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index de86b897ecda64..e3b3f19c4e736d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7444,6 +7444,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "theben_conexa": { + "name": "Theben Conexa Smart Meter Gateway", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "thermador": { "name": "Thermador", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 68d1fc739e1cfc..5149f9b618839c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3268,6 +3268,9 @@ teslemetry-stream==1.0.1 # homeassistant.components.tessie tessie-api==0.1.3 +# homeassistant.components.theben_conexa +theben-conexa-smgw==0.4.2 + # homeassistant.components.thermobeacon thermobeacon-ble==0.10.0 diff --git a/tests/components/theben_conexa/__init__.py b/tests/components/theben_conexa/__init__.py new file mode 100644 index 00000000000000..8856277eefddb3 --- /dev/null +++ b/tests/components/theben_conexa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Theben Conexa Smartmeter gateway integration.""" diff --git a/tests/components/theben_conexa/conftest.py b/tests/components/theben_conexa/conftest.py new file mode 100644 index 00000000000000..009300daaf8ceb --- /dev/null +++ b/tests/components/theben_conexa/conftest.py @@ -0,0 +1,98 @@ +"""Common fixtures for the Theben Conexa Smartmeter gateway tests.""" + +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.theben_conexa.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +TEST_CONFIG_DATA = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.theben_conexa.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_network_connection() -> Generator[AsyncMock]: + """Mock the network connectivity check for the gateway.""" + mock_network = AsyncMock(return_value=None) + + with ( + patch( + "homeassistant.components.theben_conexa.coordinator.checkNetworkConnection", + mock_network, + ), + patch( + "homeassistant.components.theben_conexa.config_flow.checkNetworkConnection", + mock_network, + ), + ): + yield mock_network + + +@pytest.fixture +def mock_conexa_create(mock_network_connection: AsyncMock) -> Generator[AsyncMock]: + """Mock the gateway factory used when creating the client.""" + mock_create = AsyncMock() + + with ( + patch( + "homeassistant.components.theben_conexa.coordinator.ConexaSMGW.create", + mock_create, + ), + patch( + "homeassistant.components.theben_conexa.config_flow.ConexaSMGW.create", + mock_create, + ), + ): + yield mock_create + + +@pytest.fixture +def mock_conexa_client(mock_conexa_create: AsyncMock) -> MagicMock: + """Mock the gateway client returned by the Theben Conexa API.""" + mock_smgw = MagicMock() + mock_smgw.gatewayInfo.smgwID = "test-gateway-id" + mock_smgw.gatewayInfo.firmwareVersion = "test-gateway-fw-version" + mock_smgw.getLatestValues = AsyncMock(return_value={}) + mock_conexa_create.return_value = mock_smgw + return mock_smgw + + +@pytest.fixture +def mock_conexa_smgw( + mock_network_connection: AsyncMock, + mock_conexa_create: AsyncMock, + mock_conexa_client: MagicMock, +) -> SimpleNamespace: + """Combine the individual Theben Conexa API mocks.""" + return SimpleNamespace( + network=mock_network_connection, + create=mock_conexa_create, + client=mock_conexa_client, + ) + + +@pytest.fixture +def mock_config_entry(mock_conexa_smgw: SimpleNamespace) -> MockConfigEntry: + """Create a configured MockConfigEntry for the integration.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=f"{mock_conexa_smgw.client.gatewayInfo.smgwID}-test-username", + data=TEST_CONFIG_DATA, + ) diff --git a/tests/components/theben_conexa/snapshots/test_sensor.ambr b/tests/components/theben_conexa/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..c6d5608fb133ed --- /dev/null +++ b/tests/components/theben_conexa/snapshots/test_sensor.ambr @@ -0,0 +1,117 @@ +# serializer version: 1 +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed', + 'platform': 'theben_conexa', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'test-gateway-id-test-username-0100010800ff', + 'unit_of_measurement': , + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Mock Title Energy consumed', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_supplied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_energy_supplied', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy supplied', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy supplied', + 'platform': 'theben_conexa', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_supplied', + 'unique_id': 'test-gateway-id-test-username-0100020800ff', + 'unit_of_measurement': , + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_supplied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Mock Title Energy supplied', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_energy_supplied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/theben_conexa/test_config_flow.py b/tests/components/theben_conexa/test_config_flow.py new file mode 100644 index 00000000000000..d43ddaba899781 --- /dev/null +++ b/tests/components/theben_conexa/test_config_flow.py @@ -0,0 +1,220 @@ +"""Test the Theben Conexa Smartmeter gateway config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import aiohttp +import pytest +from theben_conexa_smgw import ConexaSMGW + +from homeassistant import config_entries +from homeassistant.components.theben_conexa.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONFIG_DATA = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +def _assert_create_entry_result( + result: config_entries.ConfigFlowResult, + expected_data: dict[str, str], + mock_conexa_client: ConexaSMGW, +) -> None: + """Assert a successful create-entry result uses the expected unique ID.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Smartmeter Gateway" + assert result["data"] == expected_data + assert result["result"].unique_id == ( + f"{mock_conexa_client.gatewayInfo.smgwID}-{expected_data[CONF_USERNAME]}" + ) + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(aiohttp.ClientError, "invalid_auth", id="invalid-auth"), + pytest.param(ValueError, "unknown", id="unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test we handle exceptions from client creation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_conexa_smgw.network.side_effect = None + mock_conexa_smgw.create.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_conexa_smgw.create.side_effect = None + mock_conexa_smgw.create.return_value = mock_conexa_smgw.client + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_conexa_smgw.network.side_effect = aiohttp.ClientError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_conexa_smgw.network.side_effect = None + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if integration aborts if the user tries to configure an already configured smgw.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_same_gateway_different_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test that same gateway with a different username can still be configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username-2", + CONF_PASSWORD: "test-password2", + }, + ) + + _assert_create_entry_result( + result, + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username-2", + CONF_PASSWORD: "test-password2", + }, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 2 diff --git a/tests/components/theben_conexa/test_init.py b/tests/components/theben_conexa/test_init.py new file mode 100644 index 00000000000000..d2ec44f5a508be --- /dev/null +++ b/tests/components/theben_conexa/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the Theben Conexa coordinator.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_setup import FrozenDateTimeFactory + + +async def test_setup_entry_initializes_correctly( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup initializes runtime coordinator data and schedules updates.""" + # Freeze the clock so the coordinator and test advance from the same time base. + now = datetime(2026, 8, 6, 12, 33, 5, tzinfo=UTC) + freezer.move_to(now) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + scheduled_call_count = mock_conexa_smgw.client.getLatestValues.call_count + + freezer.tick(timedelta(minutes=12, seconds=35)) + + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + mock_conexa_smgw.client.getLatestValues.call_count == scheduled_call_count + 1 + ) + + # Unload to confirm the scheduled poll is cancelled and does not fire again. + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + mock_conexa_smgw.client.getLatestValues.call_count == scheduled_call_count + 1 + ) + + +async def test_setup_entry_not_ready_when_gateway_unreachable( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries when gateway is unreachable and no entities are created.""" + mock_config_entry.add_to_hass(hass) + + mock_conexa_smgw.network.side_effect = TimeoutError + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == [] + ) diff --git a/tests/components/theben_conexa/test_sensor.py b/tests/components/theben_conexa/test_sensor.py new file mode 100644 index 00000000000000..5690ec6f82616e --- /dev/null +++ b/tests/components/theben_conexa/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the Theben Conexa sensors.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.theben_conexa.const import DOMAIN, OBIS_IN, OBIS_OUT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_async_setup_entry_logs_unsupported_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check that supported keys are added while unsupported ones are skipped.""" + mock_conexa_smgw.client.getLatestValues = AsyncMock( + return_value={ + OBIS_IN: SimpleNamespace(value=1, unit="Wh"), + OBIS_OUT: SimpleNamespace(value=2, unit="Wh"), + "1-0:3.8.0": SimpleNamespace(value=3, unit="Wh"), + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_conexa_smgw.client.gatewayInfo.smgwID), + mock_config_entry.entry_id, + ) + assert device is not None + assert device.sw_version == mock_conexa_smgw.client.gatewayInfo.firmwareVersion + assert len(hass.states.async_entity_ids("sensor")) == 2 + assert "Skipping unsupported Conexa SMGW key" in caplog.text From 17edc355ae3c73085525fe1314cb67ab25763d2b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:07:56 +0300 Subject: [PATCH 14/21] Use OpenAPI 3.1.0 format for ovhcloud_ai_endpoints (#182770) --- homeassistant/components/ovhcloud_ai_endpoints/entity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/entity.py b/homeassistant/components/ovhcloud_ai_endpoints/entity.py index 7bdaf5948315c6..0acf9d63a263dc 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/entity.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -43,7 +43,11 @@ def _format_tool( """Format tool specification.""" tool_spec = FunctionDefinition( name=tool.name, - parameters=to_openapi(tool.parameters, custom_serializer=custom_serializer), + parameters=to_openapi( + tool.parameters, + custom_serializer=custom_serializer, + openapi_version="3.1.0", + ), ) if tool.description: tool_spec["description"] = tool.description From 97be9d17b382959e253f06a55bf230bddad3aa0d Mon Sep 17 00:00:00 2001 From: Bradley Florence <11214309+bradleyseanf@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:10:24 -0400 Subject: [PATCH 15/21] Add reauthentication to Monarch Money (#182773) --- .../components/monarch_money/config_flow.py | 51 ++++- .../components/monarch_money/coordinator.py | 40 ++-- .../monarch_money/quality_scale.yaml | 15 +- .../components/monarch_money/strings.json | 13 +- .../monarch_money/test_config_flow.py | 191 +++++++++++++++++- .../monarch_money/test_coordinator.py | 115 +++++++++++ 6 files changed, 385 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 8236571540a810..e2d8196ad001d7 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -1,15 +1,18 @@ """Config flow for Monarch Money integration.""" +from collections.abc import Mapping import logging from typing import Any, override +from aiohttp import ClientError, ClientResponseError +from gql.transport.exceptions import TransportError, TransportServerError from monarchmoney import LoginFailedException, RequireMFAException from monarchmoney.monarchmoney import SESSION_FILE import probatio from typedmonarchmoney import TypedMonarchMoney from typedmonarchmoney.models import MonarchSubscription -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_ID, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -112,6 +115,24 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + return await self._async_step_login("user", user_input) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Start reauthentication after credentials expire.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for credentials to renew the existing subscription token.""" + return await self._async_step_login("reauth_confirm", user_input) + + async def _async_step_login( + self, step_id: str, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Authenticate a new or existing entry, including MFA.""" errors: dict[str, str] = {} if user_input is not None: @@ -124,20 +145,38 @@ async def async_step_user( self.password = user_input[CONF_PASSWORD] return self.async_show_form( - step_id="user", + step_id=step_id, data_schema=STEP_MFA_DATA_SCHEMA, errors={"base": "mfa_required"}, ) except BadMFA: return self.async_show_form( - step_id="user", + step_id=step_id, data_schema=STEP_MFA_DATA_SCHEMA, errors={"base": "bad_mfa"}, ) - except InvalidAuth: + except InvalidAuth, LoginFailedException: + self.email = self.password = None errors["base"] = "invalid_auth" + except (ClientResponseError, TransportServerError) as err: + status = ( + err.status if isinstance(err, ClientResponseError) else err.code + ) + if status in (401, 403): + self.email = self.password = None + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ClientError, TransportError, TimeoutError: + errors["base"] = "cannot_connect" else: await self.async_set_unique_id(info[CONF_ID]) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_TOKEN: info[CONF_TOKEN]}, + ) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -145,7 +184,9 @@ async def async_step_user( data={CONF_TOKEN: info[CONF_TOKEN]}, ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id=step_id, + data_schema=STEP_MFA_DATA_SCHEMA if self.email else STEP_USER_DATA_SCHEMA, + errors=errors, ) diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py index 54f0b7cb6df63d..6011fc8fcd2107 100644 --- a/homeassistant/components/monarch_money/coordinator.py +++ b/homeassistant/components/monarch_money/coordinator.py @@ -3,10 +3,10 @@ import asyncio from dataclasses import dataclass from datetime import timedelta -from typing import override +from typing import Never, override -from aiohttp import ClientResponseError -from gql.transport.exceptions import TransportServerError +from aiohttp import ClientError, ClientResponseError +from gql.transport.exceptions import TransportError, TransportServerError from monarchmoney import LoginFailedException from typedmonarchmoney import TypedMonarchMoney from typedmonarchmoney.models import ( @@ -17,8 +17,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import LOGGER @@ -35,6 +35,17 @@ class MonarchData: type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] +def _raise_update_error(err: Exception) -> Never: + """Translate Monarch Money API errors to coordinator errors.""" + if isinstance(err, LoginFailedException) or ( + isinstance(err, (TransportServerError, ClientResponseError)) + and (err.status if isinstance(err, ClientResponseError) else err.code) + in (401, 403) + ): + raise ConfigEntryAuthFailed("Authentication failed") from err + raise UpdateFailed("Error communicating with Monarch Money") from err + + class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): """Data update coordinator for Monarch Money.""" @@ -64,8 +75,8 @@ async def _async_setup(self) -> None: sub_details: MonarchSubscription = ( await self.client.get_subscription_details() ) - except (TransportServerError, LoginFailedException, ClientResponseError) as err: - raise ConfigEntryError("Authentication failed") from err + except (LoginFailedException, TransportError, ClientError, TimeoutError) as err: + _raise_update_error(err) self.subscription_id = sub_details.id @override @@ -74,12 +85,15 @@ async def _async_update_data(self) -> MonarchData: now = dt_util.now() - account_data, cashflow_summary = await asyncio.gather( - self.client.get_accounts_as_dict_with_id_key(), - self.client.get_cashflow_summary( - start_date=f"{now.year}-01-01", end_date=f"{now.year}-12-31" - ), - ) + try: + account_data, cashflow_summary = await asyncio.gather( + self.client.get_accounts_as_dict_with_id_key(), + self.client.get_cashflow_summary( + start_date=f"{now.year}-01-01", end_date=f"{now.year}-12-31" + ), + ) + except (LoginFailedException, TransportError, ClientError, TimeoutError) as err: + _raise_update_error(err) return MonarchData(account_data=account_data, cashflow_summary=cashflow_summary) diff --git a/homeassistant/components/monarch_money/quality_scale.yaml b/homeassistant/components/monarch_money/quality_scale.yaml index 3ff4fb77d51ab4..cba15f4aab8d27 100644 --- a/homeassistant/components/monarch_money/quality_scale.yaml +++ b/homeassistant/components/monarch_money/quality_scale.yaml @@ -65,16 +65,8 @@ rules: status: todo comment: | The sensor platform does not define a PARALLEL_UPDATES constant. - reauthentication-flow: - status: todo - comment: | - The config flow does not implement async_step_reauth to handle expired or - revoked tokens without requiring the user to delete and re-add the entry. - test-coverage: - status: todo - comment: | - Only a single snapshot test exists for the sensor platform. Overall test - coverage has not been verified to exceed 95%. + reauthentication-flow: done + test-coverage: done # Gold devices: done @@ -155,8 +147,7 @@ rules: repair-issues: status: todo comment: | - No repair issues are raised. With no reauthentication flow, token expiry - has no user-facing repair path. + No repair issues are raised. stale-devices: status: todo comment: | diff --git a/homeassistant/components/monarch_money/strings.json b/homeassistant/components/monarch_money/strings.json index 983feef8ad2e4e..a3443c4bbba9d7 100644 --- a/homeassistant/components/monarch_money/strings.json +++ b/homeassistant/components/monarch_money/strings.json @@ -1,14 +1,25 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The authenticated Monarch Money account does not match the configured account." }, "error": { "bad_mfa": "Your code was invalid, please try again or use a recovery token.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "mfa_required": "Multi-factor authentication required." }, "step": { + "reauth_confirm": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "mfa_code": "[%key:component::monarch_money::config::step::user::data::mfa_code%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Your Monarch Money credentials have expired. Enter your email and password to reconnect your existing account. If required, you will also be prompted for your MFA code." + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py index b33b5f0ff676ed..e7050ba04946d9 100644 --- a/tests/components/monarch_money/test_config_flow.py +++ b/tests/components/monarch_money/test_config_flow.py @@ -1,7 +1,9 @@ """Test the Monarch Money config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, call, patch +from aiohttp import ClientResponseError +from gql.transport.exceptions import TransportError, TransportServerError from monarchmoney import LoginFailedException, RequireMFAException import pytest @@ -11,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form_simple( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock @@ -64,20 +68,51 @@ async def test_add_duplicate_entry( assert result["reason"] == "already_configured" -async def test_form_invalid_auth( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +@pytest.mark.parametrize( + ("api_error", "expected_error"), + [ + pytest.param( + LoginFailedException("invalid credentials"), + "invalid_auth", + id="login_failed", + ), + pytest.param( + ClientResponseError(None, (), status=401), + "invalid_auth", + id="client_unauthorized", + ), + pytest.param( + TransportServerError("forbidden", code=403), + "invalid_auth", + id="transport_forbidden", + ), + pytest.param( + ClientResponseError(None, (), status=500), + "cannot_connect", + id="client_server_error", + ), + pytest.param( + TransportServerError("server error", code=500), + "cannot_connect", + id="transport_server_error", + ), + ], +) +async def test_form_login_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_api: AsyncMock, + api_error: Exception, + expected_error: str, ) -> None: - """Test config flow with a login error.""" + """Test config flow login errors and recovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - # Change the login mock to raise an MFA required error - mock_config_api.return_value.login.side_effect = LoginFailedException( - "Invalid Auth" - ) + mock_config_api.return_value.login.side_effect = api_error result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -88,7 +123,7 @@ async def test_form_invalid_auth( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": expected_error} mock_config_api.return_value.login.side_effect = None result = await hass.config_entries.flow.async_configure( @@ -108,6 +143,144 @@ async def test_form_invalid_auth( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("subscription_id", "expected_reason", "expected_token", "reload_count"), + [ + pytest.param( + "222260252323873333", + "reauth_successful", + "mocked_token", + 1, + id="same_account", + ), + pytest.param( + "different-subscription", + "unique_id_mismatch", + "fake_token_of_doom", + 0, + id="different_account", + ), + ], +) +async def test_reauth_account_validation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, + subscription_id: str, + expected_reason: str, + expected_token: str, + reload_count: int, +) -> None: + """Test reauthentication only updates the matching existing entry.""" + mock_config_entry.add_to_hass(hass) + entry_id = mock_config_entry.entry_id + mock_config_api.return_value.get_subscription_details.return_value = Mock( + id=subscription_id + ) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch.object( + hass.config_entries, "async_reload", new=AsyncMock(return_value=True) + ) as mock_reload: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + assert mock_config_entry.entry_id == entry_id + assert mock_config_entry.unique_id == "222260252323873333" + assert mock_config_entry.data == {CONF_TOKEN: expected_token} + assert mock_reload.await_args_list == [call(entry_id)] * reload_count + + +async def test_reauth_mfa_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, +) -> None: + """Test an MFA reauthentication can recover from code and connection errors.""" + mock_config_entry.add_to_hass(hass) + client = mock_config_api.return_value + client.login.side_effect = RequireMFAException("mfa_required") + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + assert CONF_MFA_CODE in result["data_schema"].schema + + client.multi_factor_authenticate.side_effect = LoginFailedException("bad code") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_MFA_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "bad_mfa"} + assert CONF_MFA_CODE in result["data_schema"].schema + + client.multi_factor_authenticate.side_effect = TransportError("offline") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_MFA_CODE: "654321"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert CONF_MFA_CODE in result["data_schema"].schema + + client.multi_factor_authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_MFA_CODE: "654321"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + client.multi_factor_authenticate.assert_awaited_with( + "test-username", "test-password", "654321" + ) + + +async def test_reauth_subscription_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, +) -> None: + """Test an auth failure after login returns to the credential form.""" + mock_config_entry.add_to_hass(hass) + client = mock_config_api.return_value + client.get_subscription_details.side_effect = LoginFailedException("expired") + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert CONF_EMAIL in result["data_schema"].schema + assert CONF_PASSWORD in result["data_schema"].schema + + async def test_form_mfa( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock ) -> None: diff --git a/tests/components/monarch_money/test_coordinator.py b/tests/components/monarch_money/test_coordinator.py index 3f766496492288..af4cb2eae592d7 100644 --- a/tests/components/monarch_money/test_coordinator.py +++ b/tests/components/monarch_money/test_coordinator.py @@ -2,8 +2,17 @@ from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from gql.transport.exceptions import TransportError, TransportServerError +from monarchmoney import LoginFailedException +import pytest +from homeassistant.components.monarch_money.coordinator import ( + MonarchMoneyDataUpdateCoordinator, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration @@ -31,3 +40,109 @@ async def test_cashflow_year_follows_configured_time_zone( mock_config_api.return_value.get_cashflow_summary.assert_called_with( start_date="2026-01-01", end_date="2026-12-31" ) + + +@pytest.mark.parametrize( + "api_error", + [ + pytest.param(LoginFailedException("expired"), id="login_failed"), + pytest.param( + TransportServerError("unauthorized", code=401), id="http_unauthorized" + ), + ], +) +async def test_setup_auth_error_starts_reauthentication( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, + api_error: Exception, +) -> None: + """Test setup authentication errors start reauthentication.""" + mock_config_api.return_value.get_subscription_details.side_effect = api_error + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_update_auth_error_starts_reauthentication( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, +) -> None: + """Test refresh authentication errors start reauthentication.""" + await setup_integration(hass, mock_config_entry) + coordinator: MonarchMoneyDataUpdateCoordinator = mock_config_entry.runtime_data + mock_config_api.return_value.get_accounts_as_dict_with_id_key.side_effect = ( + TransportServerError("forbidden", code=403) + ) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not coordinator.last_update_success + state = hass.states.get("sensor.cashflow_expense_year_to_date") + assert state is not None + assert state.state == STATE_UNAVAILABLE + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize( + "api_error", + [ + pytest.param(TransportServerError("server error", code=500), id="http_error"), + pytest.param(TimeoutError(), id="timeout"), + ], +) +async def test_setup_connection_error_is_retryable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, + api_error: Exception, +) -> None: + """Test setup connection errors schedule a retry.""" + mock_config_api.return_value.get_subscription_details.side_effect = api_error + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.config_entries.flow.async_progress() == [] + + +@pytest.mark.parametrize( + "api_error", + [ + pytest.param(TransportError("transport error"), id="transport_error"), + pytest.param(ClientError("client error"), id="client_error"), + ], +) +async def test_update_connection_error_is_retryable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_api: AsyncMock, + api_error: Exception, +) -> None: + """Test refresh connection errors mark data unavailable without reauth.""" + await setup_integration(hass, mock_config_entry) + coordinator: MonarchMoneyDataUpdateCoordinator = mock_config_entry.runtime_data + mock_config_api.return_value.get_accounts_as_dict_with_id_key.side_effect = ( + api_error + ) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not coordinator.last_update_success + state = hass.states.get("sensor.cashflow_expense_year_to_date") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert hass.config_entries.flow.async_progress() == [] From 001abba7292d036a4335f175fdeeba84c317bb2f Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:11:10 +0300 Subject: [PATCH 16/21] Use OpenAPI 3.1.0 format for MCP Server JSON schemas (#182767) --- homeassistant/components/mcp_server/server.py | 4 +- tests/components/mcp_server/test_http.py | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 2386ffa153db5c..dcb1fae80ae682 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -44,7 +44,9 @@ def _format_tool( ) -> types.Tool: """Format tool specification.""" input_schema = probatio.to_openapi( - tool.parameters, custom_serializer=custom_serializer + tool.parameters, + custom_serializer=custom_serializer, + openapi_version="3.1.0", ) mcp_schema: dict[str, Any] = { "type": "object", diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index ab0aa337b00575..d764dc87f3fb7f 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -687,6 +687,51 @@ async def test_mcp_tools_list_required_parameters( assert tool.inputSchema.get("required") == expected_required +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) +@pytest.mark.parametrize( + ("parameters", "expected_schema"), + [ + pytest.param( + probatio.Schema( + {probatio.Optional("value"): probatio.Range(min=0, min_included=False)} + ), + {"type": "number", "exclusiveMinimum": 0}, + id="exclusive-minimum", + ), + pytest.param( + probatio.Schema({probatio.Optional("value"): probatio.Maybe(str)}), + {"anyOf": [{"type": "null"}, {"type": "string"}]}, + id="nullable", + ), + ], +) +async def test_mcp_tools_list_json_schema( + hass: HomeAssistant, + mcp_url: str, + mcp_client: MCPClientFactory, + hass_supervisor_access_token: str, + parameters: probatio.Schema, + expected_schema: JsonObjectType, +) -> None: + """Test tool parameters use JSON Schema 2020-12 compatible representations.""" + llm.async_register_api( + hass, + MockLLMAPI( + hass=hass, + id=TEST_LLM_API_ID, + name="Test API", + tools=[_StubTool(parameters)], + ), + ) + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.list_tools() + + tool = next(tool for tool in result.tools if tool.name == "test_tool") + assert tool.inputSchema["properties"]["value"] == expected_schema + + @pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) async def test_mcp_tools_list_metadata( hass: HomeAssistant, From e99a7290d2bec9b417182c758f7a13e852ad2f84 Mon Sep 17 00:00:00 2001 From: Bradley Florence <11214309+bradleyseanf@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:11:24 -0400 Subject: [PATCH 17/21] Enable credential autofill for Monarch Money (#182772) --- homeassistant/components/monarch_money/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index e2d8196ad001d7..192cd798bdb38f 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -32,11 +32,13 @@ probatio.Required(CONF_EMAIL): TextSelector( TextSelectorConfig( type=TextSelectorType.EMAIL, + autocomplete="username", ), ), probatio.Required(CONF_PASSWORD): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, + autocomplete="current-password", ), ), } From 14390247e6e3cd5b7f8599a4bc5d51a75b7a652c Mon Sep 17 00:00:00 2001 From: YP30 <58120331+YP30@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:12:29 +0200 Subject: [PATCH 18/21] Normalize test fixtures for irm_kmi (#182692) --- tests/components/irm_kmi/__init__.py | 14 +- tests/components/irm_kmi/conftest.py | 91 +- tests/components/irm_kmi/const.py | 15 + .../irm_kmi/snapshots/test_weather.ambr | 1260 ++++++++--------- tests/components/irm_kmi/test_config_flow.py | 92 +- tests/components/irm_kmi/test_init.py | 26 +- tests/components/irm_kmi/test_weather.py | 97 +- 7 files changed, 775 insertions(+), 820 deletions(-) create mode 100644 tests/components/irm_kmi/const.py diff --git a/tests/components/irm_kmi/__init__.py b/tests/components/irm_kmi/__init__.py index 629c80d5d9e874..8498864b3cc4d6 100644 --- a/tests/components/irm_kmi/__init__.py +++ b/tests/components/irm_kmi/__init__.py @@ -1 +1,13 @@ -"""Tests of IRM KMI integration.""" +"""Tests for the IRM KMI integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/irm_kmi/conftest.py b/tests/components/irm_kmi/conftest.py index 078c7bc5955003..61a835cc9fd246 100644 --- a/tests/components/irm_kmi/conftest.py +++ b/tests/components/irm_kmi/conftest.py @@ -1,9 +1,8 @@ """Fixtures for the IRM KMI integration tests.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from irm_kmi_api import IrmKmiApiError import pytest from homeassistant.components.irm_kmi.const import DOMAIN @@ -14,6 +13,8 @@ CONF_UNIQUE_ID, ) +from .const import CURRENT_WEATHER + from tests.common import MockConfigEntry, load_json_object_fixture @@ -21,13 +22,13 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title="Home", + title="Brussels", domain=DOMAIN, data={ CONF_LOCATION: {ATTR_LATITUDE: 50.84, ATTR_LONGITUDE: 4.35}, - CONF_UNIQUE_ID: "city country", + CONF_UNIQUE_ID: "brussels be", }, - unique_id="50.84-4.35", + unique_id="brussels be", ) @@ -39,82 +40,40 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture -def mock_get_forecast_in_benelux(): - """Mock get_forecasts_coord() returning valid data in Benelux.""" +def mock_config_flow_forecast() -> Generator[AsyncMock]: + """Mock the config flow forecast fetch for a location in Belgium.""" with patch( "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", return_value={"cityName": "Brussels", "country": "BE"}, - ): - yield - - -@pytest.fixture -def mock_get_forecast_out_benelux_then_in_belgium(): - """Mock get_forecasts_coord() returning outside then inside Benelux.""" - with patch( - "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", - side_effect=[ - {"cityName": "Outside the Benelux (Brussels)", "country": "BE"}, - {"cityName": "Brussels", "country": "BE"}, - ], - ): - yield - - -@pytest.fixture -def mock_get_forecast_api_error(): - """Mock get_forecasts_coord() so that it raises an error.""" - with patch( - "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", - side_effect=IrmKmiApiError, - ): - yield + ) as get_forecasts_coord: + yield get_forecasts_coord @pytest.fixture -def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: - """Return a mocked IrmKmi api client.""" - fixture: str = "forecast.json" - - forecast = load_json_object_fixture(fixture, "irm_kmi") +def mock_irm_kmi_api() -> Generator[MagicMock]: + """Return a mocked IRM KMI client serving parsed data.""" with patch( "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True ) as irm_kmi_api_mock: irm_kmi = irm_kmi_api_mock.return_value - irm_kmi.get_forecasts_coord.return_value = forecast + irm_kmi.get_country.return_value = "BE" + irm_kmi.get_current_weather.return_value = CURRENT_WEATHER + irm_kmi.get_daily_forecast.return_value = [] + irm_kmi.get_hourly_forecast.return_value = [] yield irm_kmi @pytest.fixture -def mock_irm_kmi_api_nl(): - """Mock get_forecasts_coord() to return a Netherlands forecast.""" - fixture: str = "forecast_nl.json" - forecast = load_json_object_fixture(fixture, "irm_kmi") - with patch( - "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", - return_value=forecast, - ): - yield +def forecast_fixture() -> str: + """Return the name of the recorded forecast to serve.""" + return "forecast.json" @pytest.fixture -def mock_irm_kmi_api_high_low_temp(): - """Mock get_forecasts_coord() to return high_low_temp forecast.""" - fixture: str = "high_low_temp.json" - forecast = load_json_object_fixture(fixture, "irm_kmi") +def mock_get_forecasts_coord(forecast_fixture: str) -> Generator[AsyncMock]: + """Mock get_forecasts_coord() to return a recorded forecast.""" with patch( - "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", - return_value=forecast, - ): - yield - - -@pytest.fixture -def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: - """Return a mocked IrmKmi api client that raises on refresh.""" - with patch( - "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True - ) as irm_kmi_api_mock: - irm_kmi = irm_kmi_api_mock.return_value - irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiError - yield irm_kmi + "homeassistant.components.irm_kmi.IrmKmiApiClientHa.get_forecasts_coord", + return_value=load_json_object_fixture(forecast_fixture, DOMAIN), + ) as get_forecasts_coord: + yield get_forecasts_coord diff --git a/tests/components/irm_kmi/const.py b/tests/components/irm_kmi/const.py new file mode 100644 index 00000000000000..9ae16dce3dce2d --- /dev/null +++ b/tests/components/irm_kmi/const.py @@ -0,0 +1,15 @@ +"""Constants shared by the IRM KMI integration tests.""" + +from irm_kmi_api import CurrentWeatherData + +WEATHER_ENTITY_ID = "weather.brussels" + +CURRENT_WEATHER = CurrentWeatherData( + condition="cloudy", + temperature=7.2, + wind_speed=25.0, + wind_gust_speed=50.0, + wind_bearing=180.0, + uv_index=0.7, + pressure=1015.0, +) diff --git a/tests/components/irm_kmi/snapshots/test_weather.ambr b/tests/components/irm_kmi/snapshots/test_weather.ambr index 6c920f163691f6..8ca05260925682 100644 --- a/tests/components/irm_kmi/snapshots/test_weather.ambr +++ b/tests/components/irm_kmi/snapshots/test_weather.ambr @@ -1,638 +1,630 @@ # serializer version: 1 -# name: test_forecast_service[daily] - dict({ - 'weather.home': dict({ - 'forecast': list([ - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2023-12-28', - 'is_daytime': True, - 'precipitation': 0.1, - 'precipitation_probability': None, - 'sunrise': '2023-12-28T08:47:43+01:00', - 'sunset': '2023-12-28T16:34:06+01:00', - 'temperature': 11.0, - 'templow': 9.0, - 'text': ''' - Waarschuwingen - Vanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel). - - Vanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur. - Vanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur. - Vanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur. - - Komende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur. - - Morgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur. - Morgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur. - Morgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. - (Bron: KNMI, 2023-12-28T06:56:00+01:00) +# name: test_forecast_service[daily-forecast_nl.json] + list([ + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-28', + 'is_daytime': True, + 'precipitation': 0.1, + 'precipitation_probability': None, + 'sunrise': '2023-12-28T08:47:43+01:00', + 'sunset': '2023-12-28T16:34:06+01:00', + 'temperature': 11.0, + 'templow': 9.0, + 'text': ''' + Waarschuwingen + Vanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel). + + Vanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur. + Vanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur. + Vanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur. + + Komende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur. + + Morgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur. + Morgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur. + Morgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. + (Bron: KNMI, 2023-12-28T06:56:00+01:00) - ''', - 'wind_bearing': 225.0, - 'wind_gust_speed': 33.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'partlycloudy', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2023-12-29', - 'is_daytime': True, - 'precipitation': 3.8, - 'precipitation_probability': None, - 'sunrise': '2023-12-29T08:47:48+01:00', - 'sunset': '2023-12-29T16:35:00+01:00', - 'temperature': 10.0, - 'text': '', - 'wind_bearing': 248.0, - 'wind_gust_speed': 28.0, - 'wind_speed': 26.0, - }), - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2023-12-30', - 'is_daytime': True, - 'precipitation': 1.7, - 'precipitation_probability': None, - 'sunrise': '2023-12-30T08:47:49+01:00', - 'sunset': '2023-12-30T16:35:57+01:00', - 'temperature': 10.0, - 'templow': 5.0, - 'text': '', - 'wind_bearing': 225.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 22.0, - }), - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2023-12-31', - 'is_daytime': True, - 'precipitation': 4.2, - 'precipitation_probability': None, - 'sunrise': '2023-12-31T08:47:47+01:00', - 'sunset': '2023-12-31T16:36:56+01:00', - 'temperature': 9.0, - 'templow': 7.0, - 'text': '', - 'wind_bearing': 203.0, - 'wind_gust_speed': 31.0, - 'wind_speed': 30.0, - }), - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2024-01-01', - 'is_daytime': True, - 'precipitation': 2.2, - 'precipitation_probability': None, - 'sunrise': '2024-01-01T08:47:42+01:00', - 'sunset': '2024-01-01T16:37:59+01:00', - 'temperature': 7.0, - 'templow': 5.0, - 'text': '', - 'wind_bearing': 225.0, - 'wind_gust_speed': 28.0, - 'wind_speed': 23.0, - }), - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2024-01-02', - 'is_daytime': True, - 'precipitation': 1.4, - 'precipitation_probability': None, - 'sunrise': '2024-01-02T08:47:32+01:00', - 'sunset': '2024-01-02T16:39:04+01:00', - 'temperature': 6.0, - 'templow': 3.0, - 'text': '', - 'wind_bearing': 225.0, - 'wind_gust_speed': 16.0, - 'wind_speed': 15.0, - }), - dict({ - 'condition': 'pouring', - 'condition_2': None, - 'condition_evol': , - 'datetime': '2024-01-03', - 'is_daytime': True, - 'precipitation': 1.0, - 'precipitation_probability': None, - 'sunrise': '2024-01-03T08:47:20+01:00', - 'sunset': '2024-01-03T16:40:12+01:00', - 'temperature': 6.0, - 'templow': 3.0, - 'text': '', - 'wind_bearing': 203.0, - 'wind_gust_speed': 14.0, - 'wind_speed': 13.0, - }), - ]), + ''', + 'wind_bearing': 225.0, + 'wind_gust_speed': 33.0, + 'wind_speed': 32.0, }), - }) + dict({ + 'condition': 'partlycloudy', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-29', + 'is_daytime': True, + 'precipitation': 3.8, + 'precipitation_probability': None, + 'sunrise': '2023-12-29T08:47:48+01:00', + 'sunset': '2023-12-29T16:35:00+01:00', + 'temperature': 10.0, + 'text': '', + 'wind_bearing': 248.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-30', + 'is_daytime': True, + 'precipitation': 1.7, + 'precipitation_probability': None, + 'sunrise': '2023-12-30T08:47:49+01:00', + 'sunset': '2023-12-30T16:35:57+01:00', + 'temperature': 10.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-31', + 'is_daytime': True, + 'precipitation': 4.2, + 'precipitation_probability': None, + 'sunrise': '2023-12-31T08:47:47+01:00', + 'sunset': '2023-12-31T16:36:56+01:00', + 'temperature': 9.0, + 'templow': 7.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 30.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-01', + 'is_daytime': True, + 'precipitation': 2.2, + 'precipitation_probability': None, + 'sunrise': '2024-01-01T08:47:42+01:00', + 'sunset': '2024-01-01T16:37:59+01:00', + 'temperature': 7.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-02', + 'is_daytime': True, + 'precipitation': 1.4, + 'precipitation_probability': None, + 'sunrise': '2024-01-02T08:47:32+01:00', + 'sunset': '2024-01-02T16:39:04+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 16.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-03', + 'is_daytime': True, + 'precipitation': 1.0, + 'precipitation_probability': None, + 'sunrise': '2024-01-03T08:47:20+01:00', + 'sunset': '2024-01-03T16:40:12+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 13.0, + }), + ]) # --- -# name: test_forecast_service[hourly] - dict({ - 'weather.home': dict({ - 'forecast': list([ - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T15:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1008.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 33.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T16:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1008.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T17:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1007.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T18:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1007.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T19:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T20:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-22T21:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-22T22:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.7, - 'precipitation_probability': 70, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-22T23:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.1, - 'precipitation_probability': 10, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 37.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T00:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 20, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-23T01:00:00+02:00', - 'is_daytime': False, - 'precipitation': 1.9, - 'precipitation_probability': 80, - 'pressure': 1005.0, - 'temperature': 10.0, - 'wind_bearing': 225.0, - 'wind_speed': 31.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-23T02:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.6, - 'precipitation_probability': 70, - 'pressure': 1005.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 38.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T03:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T04:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 34.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T05:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 35.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T06:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 34.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T07:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T08:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 31.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T09:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 31.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T10:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T11:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 32.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T12:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 34.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T13:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 33.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T14:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 31.0, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2025-09-23T15:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 10.0, - 'wind_bearing': 248.0, - 'wind_speed': 28.0, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2025-09-23T16:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 9.0, - 'wind_bearing': 248.0, - 'wind_speed': 24.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2025-09-23T17:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 8.0, - 'wind_bearing': 225.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T18:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 8.0, - 'wind_bearing': 225.0, - 'wind_speed': 18.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T19:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1005.0, - 'temperature': 8.0, - 'wind_bearing': 203.0, - 'wind_speed': 15.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-23T20:00:00+02:00', - 'is_daytime': False, - 'precipitation': 5.7, - 'precipitation_probability': 100, - 'pressure': 1005.0, - 'temperature': 8.0, - 'wind_bearing': 248.0, - 'wind_speed': 22.0, - }), - dict({ - 'condition': 'pouring', - 'datetime': '2025-09-23T21:00:00+02:00', - 'is_daytime': False, - 'precipitation': 3.8, - 'precipitation_probability': 100, - 'pressure': 1006.0, - 'temperature': 7.0, - 'wind_bearing': 270.0, - 'wind_speed': 26.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-23T22:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1006.0, - 'temperature': 8.0, - 'wind_bearing': 248.0, - 'wind_speed': 24.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2025-09-23T23:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1007.0, - 'temperature': 7.0, - 'wind_bearing': 248.0, - 'wind_speed': 22.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T00:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1008.0, - 'temperature': 8.0, - 'wind_bearing': 270.0, - 'wind_speed': 26.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2025-09-24T01:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1007.0, - 'temperature': 7.0, - 'wind_bearing': 270.0, - 'wind_speed': 26.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2025-09-24T02:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1008.0, - 'temperature': 7.0, - 'wind_bearing': 270.0, - 'wind_speed': 24.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T03:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1008.0, - 'temperature': 7.0, - 'wind_bearing': 270.0, - 'wind_speed': 24.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2025-09-24T04:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1009.0, - 'temperature': 7.0, - 'wind_bearing': 248.0, - 'wind_speed': 23.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2025-09-24T05:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1009.0, - 'temperature': 6.0, - 'wind_bearing': 248.0, - 'wind_speed': 23.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T06:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1009.0, - 'temperature': 6.0, - 'wind_bearing': 248.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T07:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1010.0, - 'temperature': 6.0, - 'wind_bearing': 248.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T08:00:00+02:00', - 'is_daytime': False, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1011.0, - 'temperature': 6.0, - 'wind_bearing': 248.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2025-09-24T09:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1011.0, - 'temperature': 6.0, - 'wind_bearing': 248.0, - 'wind_speed': 13.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2025-09-24T10:00:00+02:00', - 'is_daytime': True, - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'pressure': 1012.0, - 'temperature': 5.0, - 'wind_bearing': 225.0, - 'wind_speed': 12.0, - }), - ]), +# name: test_forecast_service[hourly-forecast_nl.json] + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 33.0, }), - }) + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.7, + 'precipitation_probability': 70, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.1, + 'precipitation_probability': 10, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 37.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 20, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 1.9, + 'precipitation_probability': 80, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.6, + 'precipitation_probability': 70, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 38.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T11:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T12:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T13:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 33.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T14:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 28.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-23T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 18.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 203.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 5.7, + 'precipitation_probability': 100, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 3.8, + 'precipitation_probability': 100, + 'pressure': 1006.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 8.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1010.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-24T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1012.0, + 'temperature': 5.0, + 'wind_bearing': 225.0, + 'wind_speed': 12.0, + }), + ]) # --- -# name: test_weather_nl[weather.home-entry] +# name: test_weather_nl[forecast_nl.json][weather.brussels-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -646,7 +638,7 @@ 'disabled_by': None, 'domain': 'weather', 'entity_category': None, - 'entity_id': 'weather.home', + 'entity_id': 'weather.brussels', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -665,15 +657,15 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'city country', + 'unique_id': 'brussels be', 'unit_of_measurement': None, }) # --- -# name: test_weather_nl[weather.home-state] +# name: test_weather_nl[forecast_nl.json][weather.brussels-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Weather data from the Royal Meteorological Institute of Belgium meteo.be', - : 'Home', + : 'Brussels', : , : 1008.0, : , @@ -687,7 +679,7 @@ : , }), 'context': , - 'entity_id': 'weather.home', + 'entity_id': 'weather.brussels', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/irm_kmi/test_config_flow.py b/tests/components/irm_kmi/test_config_flow.py index e46f6bc8904b6e..301aa323e2029b 100644 --- a/tests/components/irm_kmi/test_config_flow.py +++ b/tests/components/irm_kmi/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for the IRM KMI config flow.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock +from irm_kmi_api import IrmKmiApiError import pytest from homeassistant.components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE, DOMAIN @@ -15,55 +16,43 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture -@pytest.mark.usefixtures("mock_setup_entry") -async def test_full_user_flow( - hass: HomeAssistant, mock_get_forecast_in_benelux: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_config_flow_forecast") +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Brussels" - assert result.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Brussels" + assert result["data"] == { CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}, CONF_UNIQUE_ID: "brussels be", } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_user_flow_home( - hass: HomeAssistant, mock_get_forecast_in_benelux: MagicMock -) -> None: - """Test the full user configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, - ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Brussels" + assert result["result"].unique_id == "brussels be" @pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_location_out_benelux( - hass: HomeAssistant, mock_get_forecast_out_benelux_then_in_belgium: MagicMock + hass: HomeAssistant, mock_config_flow_forecast: AsyncMock ) -> None: - """Test configuration flow with a zone outside of Benelux.""" + """Test configuration flow with a location outside of Benelux.""" + mock_config_flow_forecast.side_effect = [ + await async_load_json_object_fixture( + hass, "forecast_out_of_benelux.json", DOMAIN + ), + mock_config_flow_forecast.return_value, + ] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -73,22 +62,24 @@ async def test_config_flow_location_out_benelux( user_input={CONF_LOCATION: {ATTR_LATITUDE: 0.123, ATTR_LONGITUDE: 0.456}}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert CONF_LOCATION in result.get("errors") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_LOCATION: "out_of_benelux"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, ) - assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Brussels" @pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_with_api_error( - hass: HomeAssistant, mock_get_forecast_api_error: MagicMock + hass: HomeAssistant, mock_config_flow_forecast: AsyncMock ) -> None: """Test when API returns an error during the configuration flow.""" + mock_config_flow_forecast.side_effect = IrmKmiApiError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -98,34 +89,27 @@ async def test_config_flow_with_api_error( user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, ) - assert result.get("type") is FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_error" -@pytest.mark.usefixtures("mock_setup_entry") -async def test_setup_twice_same_location( - hass: HomeAssistant, mock_get_forecast_in_benelux: MagicMock +@pytest.mark.usefixtures("mock_setup_entry", "mock_config_flow_forecast") +async def test_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test when the user tries to set up the weather twice for the same location.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, - ) - assert result.get("type") is FlowResultType.CREATE_ENTRY + """Test the flow aborts when the location is already configured.""" + mock_config_entry.add_to_hass(hass) - # Set up a second time result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, ) - assert result.get("type") is FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_option_flow( @@ -136,9 +120,7 @@ async def test_option_flow( assert not mock_config_entry.options - result = await hass.config_entries.options.async_init( - mock_config_entry.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py index 4fa310ab81a82a..8adedfdda3895c 100644 --- a/tests/components/irm_kmi/test_init.py +++ b/tests/components/irm_kmi/test_init.py @@ -1,43 +1,43 @@ """Tests for the IRM KMI integration.""" -from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +from irm_kmi_api import IrmKmiApiError +import pytest -from homeassistant.components.irm_kmi.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_irm_kmi_api") async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_irm_kmi_api: AsyncMock, ) -> None: """Test the IRM KMI configuration entry loading/unloading.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED async def test_config_entry_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_exception_irm_kmi_api: AsyncMock, + mock_irm_kmi_api: MagicMock, ) -> None: """Test the IRM KMI configuration entry not ready.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError + + await setup_integration(hass, mock_config_entry) - assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1 + assert mock_irm_kmi_api.refresh_forecasts_coord.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/irm_kmi/test_weather.py b/tests/components/irm_kmi/test_weather.py index 44456f4f4b96c7..7b9ea4d44ddcf0 100644 --- a/tests/components/irm_kmi/test_weather.py +++ b/tests/components/irm_kmi/test_weather.py @@ -1,6 +1,6 @@ """Test for the weather entity of the IRM KMI integration.""" -from unittest.mock import AsyncMock +from typing import Any import pytest from syrupy.assertion import SnapshotAssertion @@ -13,26 +13,46 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er +from . import setup_integration +from .const import WEATHER_ENTITY_ID + from tests.common import MockConfigEntry, snapshot_platform +async def _get_forecast( + hass: HomeAssistant, forecast_type: str +) -> list[dict[str, Any]]: + """Return the forecast from weather.get_forecasts.""" + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_ENTITY_ID: WEATHER_ENTITY_ID, + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + return response[WEATHER_ENTITY_ID]["forecast"] + + +@pytest.mark.usefixtures("mock_get_forecasts_coord") +@pytest.mark.parametrize("forecast_fixture", ["forecast_nl.json"]) @pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") async def test_weather_nl( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_irm_kmi_api_nl: AsyncMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: """Test weather with forecast from the Netherland.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("mock_get_forecasts_coord") +@pytest.mark.parametrize("forecast_fixture", ["forecast_nl.json"]) @pytest.mark.parametrize( "forecast_type", ["daily", "hourly"], @@ -41,60 +61,35 @@ async def test_weather_nl( async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, - mock_irm_kmi_api_nl: AsyncMock, mock_config_entry: MockConfigEntry, forecast_type: str, ) -> None: """Test multiple forecast.""" - mock_config_entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - response = await hass.services.async_call( - WEATHER_DOMAIN, - SERVICE_GET_FORECASTS, - { - ATTR_ENTITY_ID: "weather.home", - "type": forecast_type, - }, - blocking=True, - return_response=True, - ) - assert response == snapshot + assert await _get_forecast(hass, forecast_type) == snapshot +@pytest.mark.usefixtures("mock_get_forecasts_coord") +@pytest.mark.parametrize("forecast_fixture", ["high_low_temp.json"]) @pytest.mark.freeze_time("2024-01-21T14:15:00+01:00") -@pytest.mark.parametrize( - "forecast_type", - ["daily", "hourly"], -) -async def test_weather_higher_temp_at_night( +async def test_daily_forecast_night_low_above_day_high( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_irm_kmi_api_high_low_temp: AsyncMock, - forecast_type: str, ) -> None: - """Test templow is always lower than temperature.""" + """Test a night low above the day high is swapped into the first day.""" # Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8 - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - response = await hass.services.async_call( - WEATHER_DOMAIN, - SERVICE_GET_FORECASTS, - { - ATTR_ENTITY_ID: "weather.home", - "type": forecast_type, - }, - blocking=True, - return_response=True, - ) - for forecast in response["weather.home"]["forecast"]: - assert ( - forecast.get("native_temperature") is None - or forecast.get("native_templow") is None - or forecast["native_temperature"] >= forecast["native_templow"] - ) + await setup_integration(hass, mock_config_entry) + + assert [ + (forecast["temperature"], forecast["templow"]) + for forecast in await _get_forecast(hass, "daily") + ] == [ + (4.0, 3.0), + (10.0, 1.0), + (8.0, 3.0), + (12.0, 10.0), + (8.0, 2.0), + (8.0, 6.0), + (6.0, -2.0), + ] From c58924cb5059fd19ff391238e5bb07721640a4cc Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:13:30 +0300 Subject: [PATCH 19/21] Use OpenAPI 3.1.0 format for Ollama JSON schemas (#182761) --- homeassistant/components/ollama/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index b3c2d2d795be0e..06370e0f356a71 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -43,7 +43,9 @@ def _format_tool( tool_spec = { "name": tool.name, "parameters": probatio.to_openapi( - tool.parameters, custom_serializer=custom_serializer + tool.parameters, + custom_serializer=custom_serializer, + openapi_version="3.1.0", ), } if tool.description: @@ -238,6 +240,7 @@ async def _async_handle_chat_log( if chat_log.llm_api else llm.selector_serializer ), + openapi_version="3.1.0", ) # Get response From 96ff6c0165db0d65ae040d4a6ee9d0a35ecf2673 Mon Sep 17 00:00:00 2001 From: elp3dr0 <64701822+elp3dr0@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:14:10 +0100 Subject: [PATCH 20/21] Fix templated Mealie mealplan ID handling (#182615) --- homeassistant/components/mealie/services.py | 14 +++--- tests/components/mealie/test_services.py | 55 ++++++++++++++++----- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 74f7876bd1dbb5..d81f4ad2d7128c 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -116,7 +116,7 @@ SERVICE_DELETE_MEALPLAN_SCHEMA = probatio.Schema( { probatio.Required(ATTR_CONFIG_ENTRY_ID): str, - probatio.Required(ATTR_MEALPLAN_ID): str, + probatio.Required(ATTR_MEALPLAN_ID): probatio.Any(str, int), } ) SERVICE_UPDATE_MEALPLAN = "update_mealplan" @@ -124,7 +124,7 @@ probatio.Schema( { probatio.Required(ATTR_CONFIG_ENTRY_ID): str, - probatio.Required(ATTR_MEALPLAN_ID): str, + probatio.Required(ATTR_MEALPLAN_ID): probatio.Any(str, int), probatio.Required(ATTR_DATE): cv.date, probatio.Required(ATTR_ENTRY_TYPE): probatio.In( [x.lower() for x in MealplanEntryType] @@ -135,7 +135,7 @@ probatio.Schema( { probatio.Required(ATTR_CONFIG_ENTRY_ID): str, - probatio.Required(ATTR_MEALPLAN_ID): str, + probatio.Required(ATTR_MEALPLAN_ID): probatio.Any(str, int), probatio.Required(ATTR_DATE): cv.date, probatio.Required(ATTR_ENTRY_TYPE): probatio.In( [x.lower() for x in MealplanEntryType] @@ -319,7 +319,7 @@ async def _async_delete_mealplan(call: ServiceCall) -> ServiceResponse: entry: MealieConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - mealplan_id = call.data[ATTR_MEALPLAN_ID] + mealplan_id = int(call.data[ATTR_MEALPLAN_ID]) client = entry.runtime_data.client try: @@ -335,7 +335,7 @@ async def _async_delete_mealplan(call: ServiceCall) -> ServiceResponse: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mealplan_not_found", - translation_placeholders={"mealplan_id": mealplan_id}, + translation_placeholders={"mealplan_id": str(mealplan_id)}, ) from err return None @@ -345,7 +345,7 @@ async def _async_update_mealplan(call: ServiceCall) -> ServiceResponse: entry: MealieConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - mealplan_id = call.data[ATTR_MEALPLAN_ID] + mealplan_id = int(call.data[ATTR_MEALPLAN_ID]) mealplan_date = call.data[ATTR_DATE] entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) client = entry.runtime_data.client @@ -370,7 +370,7 @@ async def _async_update_mealplan(call: ServiceCall) -> ServiceResponse: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mealplan_not_found", - translation_placeholders={"mealplan_id": mealplan_id}, + translation_placeholders={"mealplan_id": str(mealplan_id)}, ) from err if call.return_response: return {"mealplan": asdict(mealplan)} diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index e9478905584fcb..aa2f4e5139c342 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -413,11 +413,22 @@ async def test_service_delete_mealplan( SERVICE_DELETE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: "16", }, blocking=True, ) - mock_mealie_client.delete_mealplan.assert_called_with("mealplan_id") + mock_mealie_client.delete_mealplan.assert_called_with(16) + + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: 16, + }, + blocking=True, + ) + mock_mealie_client.delete_mealplan.assert_called_with(16) async def test_service_delete_mealplan_not_found( @@ -436,7 +447,7 @@ async def test_service_delete_mealplan_not_found( SERVICE_DELETE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "invalid_mealplan_id", + ATTR_MEALPLAN_ID: "16", }, blocking=True, ) @@ -484,7 +495,7 @@ async def test_service_update_mealplan( SERVICE_UPDATE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: "16", ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch", } @@ -494,7 +505,25 @@ async def test_service_update_mealplan( ) assert response == snapshot mock_mealie_client.update_mealplan.assert_called_with( - "mealplan_id", date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs + 16, date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs + ) + + mock_mealie_client.update_mealplan.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "16", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + } + | payload, + blocking=True, + return_response=False, + ) + mock_mealie_client.update_mealplan.assert_called_with( + 16, date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs ) mock_mealie_client.update_mealplan.reset_mock() @@ -503,7 +532,7 @@ async def test_service_update_mealplan( SERVICE_UPDATE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: 16, ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch", } @@ -512,7 +541,7 @@ async def test_service_update_mealplan( return_response=False, ) mock_mealie_client.update_mealplan.assert_called_with( - "mealplan_id", date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs + 16, date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs ) @@ -532,7 +561,7 @@ async def test_service_update_mealplan_invalid_entry_type( SERVICE_UPDATE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: "16", ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "dessert", ATTR_NOTE_TITLE: "Note Title", @@ -559,7 +588,7 @@ async def test_service_update_mealplan_not_found( SERVICE_UPDATE_MEALPLAN, { ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, - ATTR_MEALPLAN_ID: "invalid_mealplan_id", + ATTR_MEALPLAN_ID: "16", ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch", ATTR_RECIPE_ID: "recipe_id", @@ -709,7 +738,7 @@ async def test_service_get_shopping_list_items_connection_error( ), ( SERVICE_DELETE_MEALPLAN, - {ATTR_MEALPLAN_ID: "mealplan_id"}, + {ATTR_MEALPLAN_ID: "16"}, "delete_mealplan", MealieConnectionError, HomeAssistantError, @@ -719,7 +748,7 @@ async def test_service_get_shopping_list_items_connection_error( ( SERVICE_UPDATE_MEALPLAN, { - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: "16", ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch", ATTR_RECIPE_ID: "recipe_id", @@ -786,11 +815,11 @@ async def test_services_connection_error( }, True, ), - (SERVICE_DELETE_MEALPLAN, {ATTR_MEALPLAN_ID: "mealplan_id"}, False), + (SERVICE_DELETE_MEALPLAN, {ATTR_MEALPLAN_ID: "16"}, False), ( SERVICE_UPDATE_MEALPLAN, { - ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_MEALPLAN_ID: "16", ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch", ATTR_RECIPE_ID: "recipe_id", From 113db1bdf43ecb67885f446c7fe14f6e5844008d Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Sun, 20 Sep 2026 16:20:20 +0100 Subject: [PATCH 21/21] Mark Monzo as Platinum quality (#180813) --- homeassistant/components/monzo/manifest.json | 1 + .../components/monzo/quality_scale.yaml | 83 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/monzo/quality_scale.yaml diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index f1dd32193e51e5..ea5d866e06be4f 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -8,5 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/monzo", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "platinum", "requirements": ["monzopy==1.8.0"] } diff --git a/homeassistant/components/monzo/quality_scale.yaml b/homeassistant/components/monzo/quality_scale.yaml new file mode 100644 index 00000000000000..76e8ae5b2aa3e1 --- /dev/null +++ b/homeassistant/components/monzo/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: | + This integration does not provide custom triggers. Transaction data is + exposed through event entities. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This cloud service integration cannot be discovered. + discovery: + status: exempt + comment: This cloud service integration cannot be discovered. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: This integration does not integrate physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: done + comment: All entities provide popular, low-volume financial data. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: This integration has no settings that can be reconfigured. + repair-issues: + status: exempt + comment: | + The integration uses the reauthentication flow for authentication + issues, and no other repairable issues have been identified. + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 055a140f23c5ee..b6af338c3b2a28 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -587,7 +587,6 @@ class Rule: "moehlenhoff_alpha2", "mold_indicator", "monoprice", - "monzo", "moon", "mopeka", "motion_blinds", @@ -1524,7 +1523,6 @@ class Rule: "mold_indicator", "monarch_money", "monoprice", - "monzo", "moon", "mopeka", "motion_blinds",