From 444b9a95792ead26bbcff0f80301e7f92c51315a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 19:26:59 +0100 Subject: [PATCH 001/155] Improve user-facing strings of denonavr for better translations (#138322) - fix sentence-casing for "network receiver" as this should be translated - change "Ethernet" to upper-case - replace "True/false for enable/disable" with UI-friendly description --- homeassistant/components/denonavr/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json index 6c055c5932a..192ab3bd71f 100644 --- a/homeassistant/components/denonavr/strings.json +++ b/homeassistant/components/denonavr/strings.json @@ -23,14 +23,14 @@ } }, "error": { - "discovery_error": "Failed to discover a Denon AVR Network Receiver" + "discovery_error": "Failed to discover a Denon AVR network receiver" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "Failed to connect, please try again, disconnecting mains power and ethernet cables and reconnecting them may help", - "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manufacturer did not match", - "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete" + "cannot_connect": "Failed to connect, please try again, disconnecting mains power and Ethernet cables and reconnecting them may help", + "not_denonavr_manufacturer": "Not a Denon AVR network receiver, discovered manufacturer did not match", + "not_denonavr_missing": "Not a Denon AVR network receiver, discovery information not complete" } }, "options": { @@ -64,7 +64,7 @@ "fields": { "dynamic_eq": { "name": "Dynamic equalizer", - "description": "True/false for enable/disable." + "description": "Whether DynamicEQ should be enabled or disabled." } } }, From 2cea2258a2affa0577659fc146365810a6595785 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:27:27 +0100 Subject: [PATCH 002/155] Improve type hints in forked_daapd coordinator (#138287) --- .../components/forked_daapd/coordinator.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 7a03a9075ed..2db0a75c429 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -3,8 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence import logging +from typing import Any +from pyforked_daapd import ForkedDaapdAPI + +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,15 +31,15 @@ WEBSOCKET_RECONNECT_TIME = 30 # seconds class ForkedDaapdUpdater: """Manage updates for the forked-daapd device.""" - def __init__(self, hass, api, entry_id): + def __init__(self, hass: HomeAssistant, api: ForkedDaapdAPI, entry_id: str) -> None: """Initialize.""" self.hass = hass self._api = api - self.websocket_handler = None - self._all_output_ids = set() + self.websocket_handler: asyncio.Task[None] | None = None + self._all_output_ids: set[str] = set() self._entry_id = entry_id - async def async_init(self): + async def async_init(self) -> None: """Perform async portion of class initialization.""" if not (server_config := await self._api.get_request("config")): raise PlatformNotReady @@ -51,7 +56,7 @@ class ForkedDaapdUpdater: else: _LOGGER.error("Invalid websocket port") - async def _disconnected_callback(self): + async def _disconnected_callback(self) -> None: """Send update signals when the websocket gets disconnected.""" async_dispatcher_send( self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False @@ -60,9 +65,9 @@ class ForkedDaapdUpdater: self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] ) - async def _update(self, update_types): + async def _update(self, update_types_sequence: Sequence[str]) -> None: """Private update method.""" - update_types = set(update_types) + update_types = set(update_types_sequence) update_events = {} _LOGGER.debug("Updating %s", update_types) if ( @@ -127,8 +132,8 @@ class ForkedDaapdUpdater: self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True ) - def _add_zones(self, outputs): - outputs_to_add = [] + def _add_zones(self, outputs: list[dict[str, Any]]) -> None: + outputs_to_add: list[dict[str, Any]] = [] for output in outputs: if output["id"] not in self._all_output_ids: self._all_output_ids.add(output["id"]) From 857e35b7fdaa1ed1fe34ecabe07b8d0b61a44433 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 20:22:22 +0100 Subject: [PATCH 003/155] Remove remaining occurrences of "true" / "false" in telegram_bot (#138329) Make the field description UI-friendly. --- homeassistant/components/telegram_bot/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 714e7b74db0..8f4894f42a7 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -96,7 +96,7 @@ }, "verify_ssl": { "name": "Verify SSL", - "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." + "description": "Enable or disable SSL certificate verification. Disable if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." }, "timeout": { "name": "Read timeout", @@ -530,11 +530,11 @@ }, "is_anonymous": { "name": "Is anonymous", - "description": "If the poll needs to be anonymous, defaults to True." + "description": "If the poll needs to be anonymous. This is the default." }, "allows_multiple_answers": { "name": "Allow multiple answers", - "description": "If the poll allows multiple answers, defaults to False." + "description": "If the poll allows multiple answers." }, "open_period": { "name": "Open period", From 7a556ac3ec6aee789f982ba4aebb5e7e37f5e7c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 20:48:50 +0100 Subject: [PATCH 004/155] Remove "true" / "false" and key name from yeelight.set_music_mode action (#138334) --- homeassistant/components/yeelight/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 72e400b7cf3..d53c28cb64a 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -161,11 +161,11 @@ }, "set_music_mode": { "name": "Set music mode", - "description": "Enables or disables music_mode.", + "description": "Enables or disables music mode.", "fields": { "music_mode": { "name": "Music mode", - "description": "Use true or false to enable / disable music_mode." + "description": "Whether to enable or disable music mode." } } } From 17089e822e395ac4acd829e612e39e376cdfdcd4 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Tue, 11 Feb 2025 21:26:13 +0100 Subject: [PATCH 005/155] Allow timer.finish on paused timers (#134552) * Add test for finishing already finished timer * Add test for finishing a paused timer * Allow canceled timer to be finished --- homeassistant/components/timer/__init__.py | 4 +++- tests/components/timer/test_init.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index b0ade17b9c9..b472e94a5c3 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -389,13 +389,15 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_finish(self) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE or self._end is None: + if self._state == STATUS_IDLE: return if self._listener: self._listener() self._listener = None end = self._end + if end is None: + end = dt_util.utcnow().replace(microsecond=0) self._state = STATUS_IDLE self._end = None self._remaining = None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 95baa07eaa9..3e5ecc58b5a 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -208,6 +208,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_FINISHED, "data": {}, }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, @@ -244,6 +250,18 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_RESTARTED, "data": {}, }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": EVENT_TIMER_FINISHED, + "data": {}, + }, ] expected_events = 0 From 6115def083054130da0cb488c8d3c4430f5b6c8e Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:35:03 +0100 Subject: [PATCH 006/155] Bump pyenphase to 1.25.1 (#138327) * Bump pyenphase to 1.25.1 * Add new opt_schedules to nephase_envoy test fixtures --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/fixtures/envoy_1p_metered.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_acb_batt.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_eu_batt.json | 3 ++- .../enphase_envoy/fixtures/envoy_metered_batt_relay.json | 3 ++- .../enphase_envoy/fixtures/envoy_nobatt_metered_3p.json | 3 ++- .../enphase_envoy/fixtures/envoy_tot_cons_metered.json | 3 ++- 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0b1fd8b04b9..e51a7427504 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.23.1"], + "requirements": ["pyenphase==1.25.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 5ca792b1705..84e5619cd47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1933,7 +1933,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0bfebe5673..08c3f8287bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1577,7 +1577,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 05a6f265dfb..22aeca50ca0 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -93,7 +93,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 618b40027b8..52e812f979e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -235,7 +235,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 8118630200f..30fbc8d0f4f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -223,7 +223,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 7affc1bea0d..6cfbfed1e8e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -427,7 +427,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index ff975b690ed..8c2767e33e5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -242,7 +242,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 62df69c6d88..15cf2c173cb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -88,7 +88,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, From 6abf7b525abfcab4e0b7e439764d105a65053553 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 21:46:56 +0100 Subject: [PATCH 007/155] Improve test coverage of config subentries and fix related issues (#138321) Improve test coverage of config subentries --- homeassistant/config_entries.py | 6 +- .../components/config/test_config_entries.py | 93 ++- tests/test_config_entries.py | 564 +++++++++++++++++- 3 files changed, 657 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b4de9749250..a103148e3b1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1952,7 +1952,7 @@ class ConfigEntries: Raises UnknownEntry if entry is not found. """ if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + raise UnknownEntry(entry_id) return entry @callback @@ -3423,7 +3423,7 @@ class ConfigSubentryFlow( if data_updates is not UNDEFINED: if data is not UNDEFINED: raise ValueError("Cannot set both data and data_updates") - data = entry.data | data_updates + data = subentry.data | data_updates self.hass.config_entries.async_update_subentry( entry=entry, subentry=subentry, @@ -3462,7 +3462,7 @@ class ConfigSubentryFlow( ) subentry_id = self._reconfigure_subentry_id if subentry_id not in entry.subentries: - raise UnknownEntry + raise UnknownSubEntry(subentry_id) return entry.subentries[subentry_id] diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 28161c0182c..a31836b598c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1192,7 +1192,7 @@ async def test_subentry_flow(hass: HomeAssistant, client) -> None: async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: - """Test we can start a subentry reconfigure flow.""" + """Test we can start and finish a subentry reconfigure flow.""" class TestFlow(core_ce.ConfigFlow): class SubentryFlowHandler(core_ce.ConfigSubentryFlow): @@ -1203,6 +1203,14 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: raise NotImplementedError async def async_step_reconfigure(self, user_input=None): + if user_input is not None: + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + title="Test Entry", + data={"test": "blah"}, + ) + return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema({vol.Required("enabled"): bool}), @@ -1243,7 +1251,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: assert resp.status == HTTPStatus.OK data = await resp.json() - data.pop("flow_id") + flow_id = data.pop("flow_id") assert data == { "type": "form", "handler": ["test1", "test"], @@ -1255,6 +1263,87 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: "preview": None, } + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + + entries = hass.config_entries.async_entries("test") + assert len(entries) == 1 + + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": ["test1", "test"], + "reason": "reconfigure_successful", + "type": "abort", + "description_placeholders": None, + } + + entry = hass.config_entries.async_entries()[0] + assert entry.subentries == { + "mock_id": core_ce.ConfigSubentry( + data={"test": "blah"}, + subentry_id="mock_id", + subentry_type="test", + title="Test Entry", + unique_id=None, + ), + } + + +async def test_subentry_does_not_support_reconfigure( + hass: HomeAssistant, client: TestClient +) -> None: + """Test a subentry flow that does not support reconfigure step.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id=None, + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post( + url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + response = await resp.json() + assert response == { + "message": "Handler SubentryFlowHandler doesn't support step reconfigure" + } + @pytest.mark.parametrize( ("endpoint", "method"), diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index a5cf3ad3a1a..cf022c42e94 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -536,6 +536,118 @@ async def test_remove_entry( assert not entity_entry_list +async def test_remove_subentry( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we can remove a subentry.""" + subentry_id = "blabla" + update_listener_calls = [] + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + await hass.config_entries.async_forward_entry_setups(entry, ["light"]) + return True + + mock_remove_entry = AsyncMock(return_value=None) + + entry_entity = MockEntity(unique_id="0001", name="Test Entry Entity") + subentry_entity = MockEntity(unique_id="0002", name="Test Subentry Entity") + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + async_add_entities([entry_entity]) + async_add_entities([subentry_entity], config_subentry_id=subentry_id) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == {} + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Check entity states got added + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is not None + assert len(hass.states.async_all()) == 2 + + # Check entities got added to entity registry + assert len(entity_registry.entities) == 2 + entry_entity_entry = entity_registry.entities["light.test_entry_entity"] + assert entry_entity_entry.config_entry_id == entry.entry_id + assert entry_entity_entry.config_subentry_id is None + subentry_entity_entry = entity_registry.entities["light.test_subentry_entity"] + assert subentry_entity_entry.config_entry_id == entry.entry_id + assert subentry_entity_entry.config_subentry_id == subentry_id + + # Remove subentry + result = manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + await hass.async_block_till_done() + + # Check that remove went well + assert result is True + + # Check the remove callback was not invoked. + assert mock_remove_entry.call_count == 0 + + # Check that the config subentry was removed. + assert entry.subentries == {} + + # Check that entity state has been removed + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is None + assert len(hass.states.async_all()) == 1 + + # Check that entity registry entry has been removed + entity_entry_list = list(entity_registry.entities) + assert entity_entry_list == ["light.test_entry_entity"] + + # Try to remove the subentry again + with pytest.raises(config_entries.UnknownSubEntry): + manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + + async def test_remove_entry_non_unique_unique_id( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1544,6 +1656,63 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_updating_subentry_data( + manager: config_entries.ConfigEntries, freezer: FrozenDateTimeFactory +) -> None: + """Test that we can update an entry data.""" + created = dt_util.utcnow() + subentry_id = "blabla" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + subentry = entry.subentries[subentry_id] + entry.add_to_manager(manager) + + assert len(manager.async_entries()) == 1 + assert manager.async_entries()[0] == entry + assert entry.created_at == created + assert entry.modified_at == created + + freezer.tick() + + assert manager.async_update_subentry(entry, subentry) is False + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == created + assert manager.async_entries()[0].modified_at == created + + freezer.tick() + modified = dt_util.utcnow() + + assert manager.async_update_subentry(entry, subentry, data={"second": True}) is True + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == modified + assert manager.async_entries()[0].modified_at == modified + + async def test_update_subentry_and_trigger_listener( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1575,12 +1744,27 @@ async def test_update_subentry_and_trigger_listener( assert entry.subentries == expected_subentries assert len(update_listener_calls) == 1 + assert ( + manager.async_update_subentry( + entry, + subentry, + data={"test": "test2"}, + title="New title", + unique_id="test2", + ) + is True + ) + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + expected_subentries = {} assert manager.async_remove_subentry(entry, subentry.subentry_id) is True await hass.async_block_till_done(wait_background_tasks=True) assert entry.subentries == expected_subentries - assert len(update_listener_calls) == 2 + assert len(update_listener_calls) == 3 async def test_setup_raise_not_ready( @@ -2039,6 +2223,58 @@ async def test_entry_subentry( } +async def test_subentry_flow( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can execute a subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + async def async_step_user(self, user_input=None): + return self.async_create_entry( + title="Mock title", + data={"second": True}, + unique_id="test", + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": "user"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentry_types == { + "test": {"supports_reconfigure": False} + } + + async def test_entry_subentry_non_string( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -6002,6 +6238,207 @@ async def test_update_entry_and_reload( assert len(comp.async_unload_entry.mock_calls) == calls_entry_load_unload[1] +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + None, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + None, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + None, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + ValueError, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: type[Exception] | None, +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + **kwargs, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + err: Exception + with mock_config_flow("comp", TestFlow): + try: + result = await entry.start_subentry_reconfigure_flow( + hass, "test", subentry_id + ) + except Exception as ex: # noqa: BLE001 + err = ex + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + if raises: + assert isinstance(err, raises) + else: + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: + """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_create_entry(title="New Subentry", data={}) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises(ValueError, match="Source is reconfigure, expected user"), + ): + await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + + await hass.async_block_till_done() + + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + title="Test", + unique_id="1234", + ) + } + + @pytest.mark.parametrize("unique_id", [["blah", "bleh"], {"key": "value"}]) async def test_unhashable_unique_id_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any @@ -6545,6 +6982,23 @@ async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry(entry, unique_id="new_id") +async def test_updating_non_added_subentry_raises(hass: HomeAssistant) -> None: + """Test updating a non added entry raises UnknownEntry.""" + entry = MockConfigEntry(domain="test") + subentry = config_entries.ConfigSubentry( + data={}, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + + with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + entry.add_to_hass(hass) + with pytest.raises(config_entries.UnknownSubEntry, match=subentry.subentry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + + async def test_reload_during_setup(hass: HomeAssistant) -> None: """Test reload during setup waits.""" entry = MockConfigEntry(domain="comp", data={"value": "initial"}) @@ -7488,6 +7942,114 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" +async def test_subentry_get_reconfigure_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + subentry_id = "mock_subentry_id" + entry = MockConfigEntry( + data={}, + domain="test", + entry_id="mock_entry_id", + title="entry_title", + unique_id="entry_unique_id", + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + """Test user step.""" + return await self._async_step_confirm() + + async def async_step_reconfigure(self, user_input=None): + """Test reauth step.""" + return await self._async_step_confirm() + + async def _async_step_confirm(self): + """Confirm input.""" + try: + entry = self._get_reconfigure_entry() + except ValueError as err: + reason = str(err) + else: + reason = f"Found entry {entry.title}" + try: + entry_id = self._reconfigure_entry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {entry_id}" + + try: + subentry = self._get_reconfigure_subentry() + except ValueError as err: + reason = f"{reason}/{err}" + except config_entries.UnknownSubEntry: + reason = f"{reason}/Subentry not found" + else: + reason = f"{reason}/Found subentry {subentry.title}" + try: + subentry_id = self._reconfigure_subentry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {subentry_id}" + return self.async_abort(reason=reason) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + # A reconfigure flow finds the config entry + with mock_config_flow("test", TestFlow): + result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + ) + + # The subentry_id does not exist + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "subentry_id": "01JRemoved", + }, + ) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + ) + + # A user flow does not have access to the config entry or subentry + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} + ) + assert ( + result["reason"] + == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + ) + + async def test_reauth_helper_alignment( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 8d5f927b42b2de36edfb8a49c7466f6dbb4c6357 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 12:47:36 -0800 Subject: [PATCH 008/155] Fix next authentication token error handling (#138299) --- homeassistant/components/nest/__init__.py | 13 +++--- tests/components/nest/test_init.py | 54 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 67c14bbf544..af85f1fc5ae 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import logging -from aiohttp import web +from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool auth = await api.new_auth(hass, entry) try: await auth.async_get_access_token() - except AuthException as err: - raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err - except ConfigurationException as err: - _LOGGER.error("Configuration error: %s", err) - return False + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 7d04624dcc8..c7ac5875403 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,10 +9,12 @@ relevant modes. """ from collections.abc import Generator +import datetime from http import HTTPStatus import logging from unittest.mock import AsyncMock, patch +import aiohttp from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -22,6 +24,7 @@ from google_nest_sdm.exceptions import ( import pytest from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -36,6 +39,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker PLATFORM = "sensor" +EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() + @pytest.fixture def platforms() -> list[str]: @@ -139,6 +144,55 @@ async def test_setup_device_manager_failure( assert entries[0].state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("token_expiration_time", [EXPIRED_TOKEN_TIMESTAMP]) +@pytest.mark.parametrize( + ("token_response_args", "expected_state", "expected_steps"), + [ + # Cases that retry integration setup + ( + {"status": HTTPStatus.INTERNAL_SERVER_ERROR}, + ConfigEntryState.SETUP_RETRY, + [], + ), + ({"exc": aiohttp.ClientError("No internet")}, ConfigEntryState.SETUP_RETRY, []), + # Cases that require the user to reauthenticate in a config flow + ( + {"status": HTTPStatus.BAD_REQUEST}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ( + {"status": HTTPStatus.FORBIDDEN}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ], +) +async def test_expired_token_refresh_error( + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + token_response_args: dict, + expected_state: ConfigEntryState, + expected_steps: list[str], +) -> None: + """Test errors when attempting to refresh the auth token.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + **token_response_args, + ) + + await setup_base_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is expected_state + + flows = hass.config_entries.flow.async_progress() + assert expected_steps == [flow["step_id"] for flow in flows] + + @pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) async def test_subscriber_auth_failure( hass: HomeAssistant, From 0ffbe076beb9c9a0016cb9e6423fdcfce4440e43 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Tue, 11 Feb 2025 22:08:18 +0100 Subject: [PATCH 009/155] Fix timer.cancel action fires timer.cancelled event even on canceled timers (#134507) * Fixes https://github.com/home-assistant/core/issues/116105 * Fixes unit test in accordance to documentation Timer needs to be active before it can be canceled * Allow canceling of paused timers * Add test for canceling/finishing already canceled/finished timers * Add test for finishing a paused timer, this should not be possible * Revert finish related tests * Merge branch 'timer.cancelled_fix' of git@github.com:rrooggiieerr/homeassistant-core.git into timer.cancelled_fix --------- Co-authored-by: Franck Nijhof --- homeassistant/components/timer/__init__.py | 3 +++ tests/components/timer/test_init.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index b472e94a5c3..3cf8307e9b3 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -374,6 +374,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_cancel(self) -> None: """Cancel a timer.""" + if self._state == STATUS_IDLE: + return + if self._listener: self._listener() self._listener = None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 3e5ecc58b5a..6e68b354087 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -196,6 +196,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_CANCELLED, "data": {}, }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, From 48b8ec01e3eca3c3f985133366eb16e65363ba60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 11 Feb 2025 22:05:19 +0000 Subject: [PATCH 010/155] Add logs to Cloud component support package (#138230) * Add logs to Cloud component support package * Add section for logs * Replace list with deque * Copy the deque to avoid mutation during iteration --- homeassistant/components/cloud/__init__.py | 26 +++++++++++++++- homeassistant/components/cloud/const.py | 2 ++ homeassistant/components/cloud/helpers.py | 31 +++++++++++++++++++ homeassistant/components/cloud/http_api.py | 21 +++++++++++-- .../cloud/snapshots/test_http_api.ambr | 11 +++++++ tests/components/cloud/test_http_api.py | 16 ++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/cloud/helpers.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 55ffedd2781..4528d9aa225 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from enum import Enum +import logging from typing import cast from hass_nabucasa import Cloud @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_STOP, + FORMAT_DATETIME, Platform, ) from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback @@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.loader import async_get_integration, bind_hass from homeassistant.util.signal_type import SignalType # Pre-import backup to avoid it being imported @@ -62,11 +64,13 @@ from .const import ( CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, DATA_CLOUD, + DATA_CLOUD_LOG_HANDLER, DATA_PLATFORMS_SETUP, DOMAIN, MODE_DEV, MODE_PROD, ) +from .helpers import FixedSizeQueueLogHandler from .prefs import CloudPreferences from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the Home Assistant cloud.""" + log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass) + # Process configs if DOMAIN in config: kwargs = dict(config[DOMAIN]) @@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _shutdown(event: Event) -> None: """Shutdown event.""" await cloud.stop() + logging.root.removeHandler(log_handler) + del hass.data[DATA_CLOUD_LOG_HANDLER] hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) @@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: async_register_admin_service( hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) + + +async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler: + fmt = ( + "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" + ) + handler = FixedSizeQueueLogHandler() + handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) + + integration = await async_get_integration(hass, DOMAIN) + loggers: set[str] = {"snitun", integration.pkg_path, *(integration.loggers or [])} + + for logger_name in loggers: + logging.getLogger(logger_name).addHandler(handler) + + return handler diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 3883f19d1b7..e0c15c74cab 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,12 +12,14 @@ if TYPE_CHECKING: from hass_nabucasa import Cloud from .client import CloudClient + from .helpers import FixedSizeQueueLogHandler DOMAIN = "cloud" DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN) DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey( "cloud_platforms_setup" ) +DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler") EVENT_CLOUD_EVENT = "cloud_event" REQUEST_TIMEOUT = 10 diff --git a/homeassistant/components/cloud/helpers.py b/homeassistant/components/cloud/helpers.py new file mode 100644 index 00000000000..7795a314fb7 --- /dev/null +++ b/homeassistant/components/cloud/helpers.py @@ -0,0 +1,31 @@ +"""Helpers for the cloud component.""" + +from collections import deque +import logging + +from homeassistant.core import HomeAssistant + + +class FixedSizeQueueLogHandler(logging.Handler): + """Log handler to store messages, with auto rotation.""" + + MAX_RECORDS = 500 + + def __init__(self) -> None: + """Initialize a new LogHandler.""" + super().__init__() + self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS) + + def emit(self, record: logging.LogRecord) -> None: + """Store log message.""" + self._records.append(record) + + async def get_logs(self, hass: HomeAssistant) -> list[str]: + """Get stored logs.""" + + def _get_logs() -> list[str]: + # copy the queue since it can mutate while iterating + records = self._records.copy() + return [self.format(record) for record in records] + + return await hass.async_add_executor_job(_get_logs) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index b1a845ef8b0..af1c72f54f6 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -43,6 +43,7 @@ from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DATA_CLOUD, + DATA_CLOUD_LOG_HANDLER, EVENT_CLOUD_EVENT, LOGIN_MFA_TIMEOUT, PREF_ALEXA_REPORT_STATE, @@ -397,8 +398,11 @@ class DownloadSupportPackageView(HomeAssistantView): url = "/api/cloud/support_package" name = "api:cloud:support_package" - def _generate_markdown( - self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + async def _generate_markdown( + self, + hass: HomeAssistant, + hass_info: dict[str, Any], + domains_info: dict[str, dict[str, str]], ) -> str: def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: if len(domain_info) == 0: @@ -424,6 +428,17 @@ class DownloadSupportPackageView(HomeAssistantView): "\n\n" ) + log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] + logs = "\n".join(await log_handler.get_logs(hass)) + markdown += ( + "## Full logs\n\n" + "
Logs\n\n" + "```logs\n" + f"{logs}\n" + "```\n\n" + "
\n" + ) + return markdown async def get(self, request: web.Request) -> web.Response: @@ -433,7 +448,7 @@ class DownloadSupportPackageView(HomeAssistantView): domain_health = await get_system_health_info(hass) hass_info = domain_health.pop("homeassistant", {}) - markdown = self._generate_markdown(hass_info, domain_health) + markdown = await self._generate_markdown(hass, hass_info, domain_health) return web.Response( body=markdown, diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index 9b2f2e0eb33..b15cd08c23a 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -44,6 +44,17 @@ + ## Full logs + +
Logs + + ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log + 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log + 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log + ``` + +
''' # --- diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index e4a526ceadd..c8852b911e9 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -2,12 +2,15 @@ from collections.abc import Callable, Coroutine from copy import deepcopy +import datetime from http import HTTPStatus import json +import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from hass_nabucasa import thingtalk from hass_nabucasa.auth import ( InvalidTotpCode, @@ -1869,15 +1872,18 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} +@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3) async def test_download_support_package( hass: HomeAssistant, cloud: MagicMock, set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") aioclient_mock.get( "https://cert-server/directory", exc=Exception("Unexpected exception") @@ -1936,6 +1942,16 @@ async def test_download_support_package( } ) + now = dt_util.utcnow() + freezer.move_to(datetime.datetime.fromisoformat("2025-02-10T12:00:00.0+00:00")) + logging.getLogger("hass_nabucasa.iot").info( + "This message will be dropped since this test patches MAX_RECORDS" + ) + logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log") + logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log") + logging.getLogger("homeassistant.components.cloud.client").error("Cloud log") + freezer.move_to(now) # Reset time otherwise hass_client auth fails + cloud_client = await hass_client() with ( patch.object(hass.config, "config_dir", new="config"), From 117a71cb672cbb9d43ddbba8ea38bc1863f07e6b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 01:04:05 +0100 Subject: [PATCH 011/155] Bump sentry-sdk to 1.45.1 (#138349) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 425225e07ef..4c3a7518085 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.40.3"] + "requirements": ["sentry-sdk==1.45.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84e5619cd47..cf33b7966c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2703,7 +2703,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08c3f8287bb..5c5482fde6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2182,7 +2182,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 From da1e3c29edc4954cb08e62d4dbcb8343da00a34c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 16:05:23 -0800 Subject: [PATCH 012/155] Update anthropic to use the streaming API (#138256) --- .../components/anthropic/conversation.py | 117 +++++--- .../components/anthropic/test_conversation.py | 272 ++++++++++++------ 2 files changed, 262 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 9f513509ce7..5511119d377 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,16 +1,23 @@ """Conversation support for Anthropic.""" -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal, cast +from typing import Any, Literal import anthropic +from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( + InputJSONDelta, Message, MessageParam, + MessageStreamEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, TextBlock, TextBlockParam, + TextDelta, ToolParam, ToolResultBlockParam, ToolUseBlock, @@ -109,7 +116,7 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam: type="tool_use", id=tool_call.id, name=tool_call.tool_name, - input=json.dumps(tool_call.tool_args), + input=tool_call.tool_args, ) for tool_call in chat_content.tool_calls or () ], @@ -124,6 +131,66 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam: raise ValueError(f"Unexpected content type: {type(chat_content)}") +async def _transform_stream( + result: AsyncStream[MessageStreamEvent], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + A typical stream of responses might look something like the following: + - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty TextBlock + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - ... + - RawContentBlockStopEvent + - RawContentBlockStartEvent with ToolUseBlock specifying the function name + - RawContentBlockDeltaEvent with a InputJSONDelta + - RawContentBlockDeltaEvent with a InputJSONDelta + - ... + - RawContentBlockStopEvent + - RawMessageDeltaEvent with a stop_reason='tool_use' + - RawMessageStopEvent(type='message_stop') + """ + if result is None: + raise TypeError("Expected a stream of messages") + + current_tool_call: dict | None = None + + async for response in result: + LOGGER.debug("Received response: %s", response) + + if isinstance(response, RawContentBlockStartEvent): + if isinstance(response.content_block, ToolUseBlock): + current_tool_call = { + "id": response.content_block.id, + "name": response.content_block.name, + "input": "", + } + elif isinstance(response.content_block, TextBlock): + yield {"role": "assistant"} + elif isinstance(response, RawContentBlockDeltaEvent): + if isinstance(response.delta, InputJSONDelta): + if current_tool_call is None: + raise ValueError("Unexpected delta without a tool call") + current_tool_call["input"] += response.delta.partial_json + elif isinstance(response.delta, TextDelta): + LOGGER.debug("yielding delta: %s", response.delta.text) + yield {"content": response.delta.text} + elif isinstance(response, RawContentBlockStopEvent): + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["name"], + tool_args=json.loads(current_tool_call["input"]), + ) + ] + } + current_tool_call = None + + class AnthropicConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -206,58 +273,30 @@ class AnthropicConversationEntity( # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - response = await client.messages.create( + stream = await client.messages.create( model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), messages=messages, tools=tools or NOT_GIVEN, max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), system=system.content, temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + stream=True, ) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err - LOGGER.debug("Response %s", response) - - messages.append(_message_convert(response)) - - text = "".join( + messages.extend( [ - content.text - for content in response.content - if isinstance(content, TextBlock) + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(stream) + ) ] ) - tool_inputs = [ - llm.ToolInput( - id=tool_call.id, - tool_name=tool_call.name, - tool_args=cast(dict[str, Any], tool_call.input), - ) - for tool_call in response.content - if isinstance(tool_call, ToolUseBlock) - ] - tool_results = [ - ToolResultBlockParam( - type="tool_result", - tool_use_id=tool_response.tool_call_id, - content=json.dumps(tool_response.tool_result), - ) - async for tool_response in chat_log.async_add_assistant_content( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=text, - tool_calls=tool_inputs or None, - ) - ) - ] - if tool_results: - messages.append(MessageParam(role="user", content=tool_results)) - - if not tool_inputs: + if not chat_log.unresponded_tool_results: break response_content = chat_log.content[-1] diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 2f1de3a2db9..bda9ca32b34 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,9 +1,24 @@ """Tests for the Anthropic integration.""" +from collections.abc import AsyncGenerator +from typing import Any from unittest.mock import AsyncMock, Mock, patch from anthropic import RateLimitError -from anthropic.types import Message, TextBlock, ToolUseBlock, Usage +from anthropic.types import ( + InputJSONDelta, + Message, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RawMessageStreamEvent, + TextBlock, + TextDelta, + ToolUseBlock, + Usage, +) from freezegun import freeze_time from httpx import URL, Request, Response from syrupy.assertion import SnapshotAssertion @@ -20,6 +35,81 @@ from homeassistant.util import ulid as ulid_util from tests.common import MockConfigEntry +async def stream_generator( + responses: list[RawMessageStreamEvent], +) -> AsyncGenerator[RawMessageStreamEvent]: + """Generate a response from the assistant.""" + for msg in responses: + yield msg + + +def create_messages( + content_blocks: list[RawMessageStreamEvent], +) -> list[RawMessageStreamEvent]: + """Create a stream of messages with the specified content blocks.""" + return [ + RawMessageStartEvent( + message=Message( + type="message", + id="msg_1234567890ABCDEFGHIJKLMN", + content=[], + role="assistant", + model="claude-3-5-sonnet-20240620", + usage=Usage(input_tokens=0, output_tokens=0), + ), + type="message_start", + ), + *content_blocks, + RawMessageStopEvent(type="message_stop"), + ] + + +def create_content_block( + index: int, text_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a text content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=TextBlock(text="", type="text"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=TextDelta(text=text_part, type="text_delta"), + index=index, + type="content_block_delta", + ) + for text_part in text_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_tool_use_block( + index: int, tool_id: str, tool_name: str, json_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a tool use content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ToolUseBlock( + id=tool_id, name=tool_name, input={}, type="tool_use" + ), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"), + index=index, + type="content_block_delta", + ) + for json_part in json_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -120,6 +210,13 @@ async def test_template_variables( ) as mock_create, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): + mock_create.return_value = stream_generator( + create_messages( + create_content_block( + 0, ["Okay, let", " me take care of that for you", "."] + ) + ) + ) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -129,6 +226,10 @@ async def test_template_variables( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( result ) + assert ( + result.response.speech["plain"]["speech"] + == "Okay, let me take care of that for you." + ) assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] @@ -168,39 +269,26 @@ async def test_function_call( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="I have successfully called the function", - ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) ) with ( @@ -222,6 +310,10 @@ async def test_function_call( assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "I have successfully called the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -275,39 +367,27 @@ async def test_function_exception( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="There was an error calling the function", + return stream_generator( + create_messages( + create_content_block( + 0, + ["There was an error calling the function"], ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, "Certainly, calling it now!"), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"param1": "test_value"}'], + ), + ] + ) ) with patch( @@ -324,6 +404,10 @@ async def test_function_exception( ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "There was an error calling the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -376,15 +460,10 @@ async def test_assist_api_tools_conversion( with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, - return_value=Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[TextBlock(type="text", text="Hello, how can I help you?")], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return_value=stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), ), ) as mock_create: await conversation.async_converse( @@ -425,28 +504,45 @@ async def test_conversation_id( mock_init_component, ) -> None: """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" - ) - conversation_id = result.conversation_id + def create_stream_generator(*args, **kwargs) -> Any: + return stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), + ) - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" - ) + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=create_stream_generator, + ): + result = await conversation.async_converse( + hass, "hello", "1234", Context(), agent_id="conversation.claude" + ) - assert result.conversation_id == conversation_id + result = await conversation.async_converse( + hass, "hello", None, None, agent_id="conversation.claude" + ) - unknown_id = ulid_util.ulid() + conversation_id = result.conversation_id - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" - ) + result = await conversation.async_converse( + hass, "hello", conversation_id, None, agent_id="conversation.claude" + ) - assert result.conversation_id != unknown_id + assert result.conversation_id == conversation_id - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" - ) + unknown_id = ulid_util.ulid() - assert result.conversation_id == "koala" + result = await conversation.async_converse( + hass, "hello", unknown_id, None, agent_id="conversation.claude" + ) + + assert result.conversation_id != unknown_id + + result = await conversation.async_converse( + hass, "hello", "koala", None, agent_id="conversation.claude" + ) + + assert result.conversation_id == "koala" From 1393f417ed5a77168151a92afbc4449a47a6b6c1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:06:13 -0500 Subject: [PATCH 013/155] Expose media_player async_browse_media as service (#116452) * initial commit * make fields optional * x * ruff issues * ruff issues * ruff issues * ruff issues * update example * update description * use constants * Update homeassistant/components/media_player/strings.json Co-authored-by: Joost Lekkerkerker * update service call metadata * update description * patch the demo * Update homeassistant/components/media_player/strings.json Co-authored-by: Martin Hjelmare * revert unrelated change * update test metadata * update test metadata * change patch target to be more specific --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .../components/media_player/__init__.py | 18 ++++- .../components/media_player/const.py | 1 + .../components/media_player/icons.json | 3 + .../components/media_player/services.yaml | 16 +++++ .../components/media_player/strings.json | 14 ++++ tests/components/media_player/test_init.py | 72 +++++++++++++++++++ 6 files changed, 123 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e109b0418c9..a30b01694fa 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -52,7 +52,7 @@ from homeassistant.const import ( # noqa: F401 STATE_PLAYING, STATE_STANDBY, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.deprecation import ( @@ -124,6 +124,7 @@ from .const import ( # noqa: F401 CONTENT_AUTH_EXPIRY_TIME, DOMAIN, REPEAT_MODES, + SERVICE_BROWSE_MEDIA, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_PLAY_MEDIA, @@ -201,6 +202,12 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } +MEDIA_PLAYER_BROWSE_MEDIA_SCHEMA = { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, +} + + ATTR_TO_PROPERTY = [ ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, @@ -431,6 +438,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_play_media", [MediaPlayerEntityFeature.PLAY_MEDIA], ) + component.async_register_entity_service( + SERVICE_BROWSE_MEDIA, + { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, + }, + "async_browse_media", + supports_response=SupportsResponse.ONLY, + ) component.async_register_entity_service( SERVICE_SHUFFLE_SET, {vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean}, diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index ca2f3307846..387fdb05401 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -173,6 +173,7 @@ _DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10" SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_JOIN = "join" SERVICE_PLAY_MEDIA = "play_media" +SERVICE_BROWSE_MEDIA = "browse_media" SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOURCE = "select_source" SERVICE_UNJOIN = "unjoin" diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index c11211c38ec..5008ea62d2e 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -32,6 +32,9 @@ } }, "services": { + "browse_media": { + "service": "mdi:folder-search" + }, "clear_playlist": { "service": "mdi:playlist-remove" }, diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 7338747b545..6b13a6b9c09 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -165,6 +165,22 @@ play_media: selector: boolean: +browse_media: + target: + entity: + domain: media_player + fields: + media_content_type: + required: false + example: "music" + selector: + text: + media_content_id: + required: false + example: "A:ALBUMARTIST/Beatles" + selector: + text: + select_source: target: entity: diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index be06ae22cdc..2127716cd66 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -260,6 +260,20 @@ } } }, + "browse_media": { + "name": "Browse media", + "description": "Browses the available media.", + "fields": { + "media_content_id": { + "name": "Content ID", + "description": "The ID of the content to browse. Integration dependent." + }, + "media_content_type": { + "name": "Content type", + "description": "The type of the content to browse, such as image, music, tv show, video, episode, channel, or playlist." + } + } + }, "select_source": { "name": "Select source", "description": "Sends the media player the command to change input source.", diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 9db2621f84f..38486fe5911 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,12 +10,15 @@ import voluptuous as vol from homeassistant.components import media_player from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) +from homeassistant.components.media_player.const import SERVICE_BROWSE_MEDIA from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant @@ -339,6 +342,75 @@ async def test_media_browse( assert msg["result"] == {"bla": "yo"} +async def test_media_browse_service(hass: HomeAssistant) -> None: + """Test browsing media using service call.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", + return_value=BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album1 content id", + media_content_type="album", + title="Album 1", + can_play=True, + can_expand=True, + ), + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album2 content id", + media_content_type="album", + title="Album 2", + can_play=True, + can_expand=True, + ), + ], + ), + ) as mock_browse_media: + result = await hass.services.async_call( + "media_player", + SERVICE_BROWSE_MEDIA, + { + ATTR_ENTITY_ID: "media_player.browse", + ATTR_MEDIA_CONTENT_TYPE: "album", + ATTR_MEDIA_CONTENT_ID: "title=Album*", + }, + blocking=True, + return_response=True, + ) + + mock_browse_media.assert_called_with( + media_content_type="album", media_content_id="title=Album*" + ) + browse_res: BrowseMedia = result["media_player.browse"] + assert browse_res.title == "Mock Title" + assert browse_res.media_class == "directory" + assert browse_res.media_content_type == "mock-type" + assert browse_res.media_content_id == "mock-id" + assert browse_res.can_play is False + assert browse_res.can_expand is True + assert len(browse_res.children) == 2 + assert browse_res.children[0].title == "Album 1" + assert browse_res.children[0].media_class == "album" + assert browse_res.children[0].media_content_id == "album1 content id" + assert browse_res.children[0].media_content_type == "album" + assert browse_res.children[1].title == "Album 2" + assert browse_res.children[1].media_class == "album" + assert browse_res.children[1].media_content_id == "album2 content id" + assert browse_res.children[1].media_content_type == "album" + + async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" await async_setup_component( From a6c51440e51d6ca16c856b2745a1367445412d31 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 08:55:16 +0100 Subject: [PATCH 014/155] Use test helper for creating a mocked backup agent in backup tests (#138312) * Use test helper for creating a mocked backup agent in backup tests * Adjust according to discussion --- tests/components/backup/common.py | 126 +++++---------- .../backup/snapshots/test_websocket.ambr | 48 +++--- tests/components/backup/test_http.py | 14 +- tests/components/backup/test_init.py | 7 +- tests/components/backup/test_manager.py | 81 +++++----- tests/components/backup/test_websocket.py | 150 +++++++++--------- 6 files changed, 189 insertions(+), 237 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index afdb5e47a2e..b4ebfd70fcd 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -18,7 +18,6 @@ from homeassistant.components.backup import ( ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import MockPlatform, mock_platform @@ -64,87 +63,37 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: yield i -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: + """Create a mock backup agent.""" - domain = "test" + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + if not await get_backup(backup_id): + raise BackupNotFound + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: - """Initialize the backup agent.""" - self.name = name - self.unique_id = name - if backups is None: - backups = [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="abc123", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=13, - ) - ] + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + """Get a backup.""" + return next((b for b in backups if b.backup_id == backup_id), None) - self._backup_data: bytearray | None = None - self._backups = {backup.backup_id: backup for backup in backups} - - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) - - async def async_upload_backup( - self, + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + backups.append(backup) backup_stream = await open_stream() - self._backup_data = bytearray() + backup_data = bytearray() async for chunk in backup_stream: - self._backup_data += chunk - - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return list(self._backups.values()) - - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup | None: - """Return a backup.""" - return self._backups.get(backup_id) - - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" - - -def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: - """Create a mock backup agent.""" - - async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: - """Get a backup.""" - return next((b for b in backups if b.backup_id == backup_id), None) + backup_data += chunk + backups_data[backup.backup_id] = backup_data backups = backups or [] + backups_data: dict[str, bytes] = {} mock_agent = Mock(spec=BackupAgent) - mock_agent.domain = "test" + mock_agent.domain = TEST_DOMAIN mock_agent.name = name mock_agent.unique_id = name type(mock_agent).agent_id = BackupAgent.agent_id @@ -152,7 +101,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo spec_set=[BackupAgent.async_delete_backup] ) mock_agent.async_download_backup = AsyncMock( - side_effect=BackupNotFound, spec_set=[BackupAgent.async_download_backup] + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] ) mock_agent.async_get_backup = AsyncMock( side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] @@ -161,7 +110,8 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo return_value=backups, spec_set=[BackupAgent.async_list_backups] ) mock_agent.async_upload_backup = AsyncMock( - spec_set=[BackupAgent.async_upload_backup] + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], ) return mock_agent @@ -169,12 +119,12 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, - configuration: ConfigType | None = None, *, backups: dict[str, list[AgentBackup]] | None = None, remote_agents: list[str] | None = None, -) -> bool: +) -> dict[str, Mock]: """Set up the Backup integration.""" + backups = backups or {} with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( @@ -182,36 +132,34 @@ async def setup_backup_integration( ), ): remote_agents = remote_agents or [] + remote_agents_dict = {} + for agent in remote_agents: + if not agent.startswith(f"{TEST_DOMAIN}."): + raise ValueError(f"Invalid agent_id: {agent}") + name = agent.partition(".")[2] + remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent)) platform = Mock( async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest(agent, []) for agent in remote_agents] + return_value=list(remote_agents_dict.values()) ), spec_set=BackupAgentPlatformProtocol, ) mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) assert await async_setup_component(hass, TEST_DOMAIN, {}) - - result = await async_setup_component(hass, DOMAIN, configuration or {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - if not backups: - return result - for agent_id, agent_backups in backups.items(): - if with_hassio and agent_id == LOCAL_AGENT_ID: - continue - agent = hass.data[DATA_MANAGER].backup_agents[agent_id] + if LOCAL_AGENT_ID not in backups or with_hassio: + return remote_agents_dict - async def open_stream() -> AsyncIterator[bytes]: - """Open a stream.""" - return aiter_from_iter((b"backup data",)) + agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] - for backup in agent_backups: - await agent.async_upload_backup(open_stream=open_stream, backup=backup) - if agent_id == LOCAL_AGENT_ID: - agent._loaded_backups = True + for backup in backups[LOCAL_AGENT_ID]: + await agent.async_upload_backup(open_stream=None, backup=backup) + agent._loaded_backups = True - return result + return remote_agents_dict async def setup_backup_platform( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2f063262f34..9236a0cbe0f 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3482,13 +3482,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3499,7 +3501,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3543,13 +3545,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ 'test.remote', @@ -3561,7 +3565,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3604,13 +3608,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3621,7 +3627,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3664,13 +3670,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3681,7 +3689,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3725,13 +3733,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3742,7 +3752,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3786,13 +3796,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ 'test.remote', @@ -3804,7 +3816,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 9ebf3e8bd40..a2f32d93fc3 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from .common import ( TEST_BACKUP_ABC123, - BackupAgentTest, aiter_from_iter, mock_backup_agent, setup_backup_integration, @@ -65,19 +64,16 @@ async def test_downloading_remote_backup( hass_client: ClientSessionGenerator, ) -> None: """Test downloading a remote backup.""" + await setup_backup_integration( - hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"] + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test.test"] ) client = await hass_client() - with ( - patch.object(BackupAgentTest, "async_download_backup") as download_mock, - ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - resp = await client.get("/api/backup/download/abc123?agent_id=test.test") - assert resp.status == 200 - assert await resp.content.read() == b"backup data" + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" async def test_downloading_local_encrypted_backup_file_not_found( diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 925e2cb9b7a..8a0cc2b97c0 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -20,11 +20,7 @@ async def test_setup_with_hassio( caplog: pytest.LogCaptureFixture, ) -> None: """Test the setup of the integration with hassio enabled.""" - assert await setup_backup_integration( - hass=hass, - with_hassio=True, - configuration={DOMAIN: {}}, - ) + await setup_backup_integration(hass=hass, with_hassio=True) manager = hass.data[DATA_MANAGER] assert not manager.backup_agents @@ -59,6 +55,7 @@ async def test_create_service( ) +@pytest.mark.usefixtures("supervisor_client") async def test_create_service_with_hassio(hass: HomeAssistant) -> None: """Test action backup.create does not exist with hassio.""" await setup_backup_integration(hass, with_hassio=True) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index bdcb9f068b6..b2c01774531 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import replace from io import StringIO import json @@ -58,7 +58,7 @@ from .common import ( TEST_BACKUP_DEF456, TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456, - BackupAgentTest, + mock_backup_agent, setup_backup_platform, ) @@ -524,7 +524,7 @@ async def test_initiate_backup( ) -> None: """Test generate backup.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -771,7 +771,7 @@ async def test_initiate_backup_with_agent_error( "with_automatic_settings": True, }, ] - remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1120,7 +1120,7 @@ async def test_create_backup_failure_raises_issue( issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: """Test backup issue is cleared after backup is created.""" - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -1180,7 +1180,7 @@ async def test_initiate_backup_non_agent_upload_error( """Test an unknown or writer upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1298,7 +1298,7 @@ async def test_initiate_backup_with_task_error( create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1409,7 +1409,8 @@ async def test_initiate_backup_file_error( """Test file error during generate backup.""" agent_ids = ["test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -1513,26 +1514,21 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count -class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): - """Local backup agent.""" - - def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to an existing backup.""" - return Path("test.tar") - - def get_new_backup_path(self, backup: AgentBackup) -> Path: - """Return the local path to a new backup.""" - return Path("test.tar") +def _mock_local_backup_agent(name: str) -> Mock: + local_agent = mock_backup_agent(name) + # This makes the local_agent pass isinstance checks for LocalBackupAgent + local_agent.mock_add_spec(LocalBackupAgent) + return local_agent @pytest.mark.parametrize( - ("agent_class", "num_local_agents"), - [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)], + ("agent_creator", "num_local_agents"), + [(_mock_local_backup_agent, 2), (mock_backup_agent, 1)], ) async def test_loading_platform_with_listener( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - agent_class: type[BackupAgentTest], + agent_creator: Callable[[str], Mock], num_local_agents: int, ) -> None: """Test loading a backup agent platform which can be listened to.""" @@ -1540,7 +1536,7 @@ async def test_loading_platform_with_listener( assert await async_setup_component(hass, DOMAIN, {}) manager = hass.data[DATA_MANAGER] - get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) + get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")]) register_listener_mock = Mock() await setup_backup_platform( @@ -1565,7 +1561,7 @@ async def test_loading_platform_with_listener( register_listener_mock.assert_called_once_with(hass, listener=ANY) get_agents_mock.reset_mock() - get_agents_mock.return_value = [agent_class("remote2", backups=[])] + get_agents_mock.return_value = [agent_creator("remote2")] listener = register_listener_mock.call_args[1]["listener"] listener() @@ -1609,7 +1605,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1639,7 +1635,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1678,7 +1674,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: 2, 1, ["Test_1970-01-01_00.00_00000000.tar"], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 0, ), @@ -1696,7 +1692,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: 2, 0, [], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 1, ), @@ -1714,7 +1710,7 @@ async def test_receive_backup( temp_file_unlink_call_count: int, ) -> None: """Test receive backup and upload to the local and a remote agent.""" - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1754,8 +1750,12 @@ async def test_receive_backup( assert move_mock.call_count == move_call_count for index, name in enumerate(move_path_names): assert move_mock.call_args_list[index].args[1].name == name - assert remote_agent._backups == remote_agent_backups - assert remote_agent._backup_data == remote_agent_backup_data + for backup_id, (backup, expected_backup_data) in remote_agent_backups.items(): + assert await remote_agent.async_get_backup(backup_id) == backup + backup_data = bytearray() + async for chunk in await remote_agent.async_download_backup(backup_id): + backup_data += chunk + assert backup_data == expected_backup_data assert unlink_mock.call_count == temp_file_unlink_call_count @@ -1911,7 +1911,7 @@ async def test_receive_backup_agent_error( "with_automatic_settings": True, }, ] - remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -2065,7 +2065,7 @@ async def test_receive_backup_non_agent_upload_error( ) -> None: """Test non agent upload error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -2193,7 +2193,7 @@ async def test_receive_backup_file_write_error( ) -> None: """Test file write error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2304,7 +2304,7 @@ async def test_receive_backup_read_tar_error( ) -> None: """Test read tar error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2484,7 +2484,8 @@ async def test_receive_backup_file_read_error( ) -> None: """Test file read error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2654,7 +2655,7 @@ async def test_restore_backup( ) -> None: """Test restore backup.""" password = password_param.get("password") - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -2761,7 +2762,7 @@ async def test_restore_backup_wrong_password( ) -> None: """Test restore backup wrong password.""" password = "hunter2" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -2988,7 +2989,7 @@ async def test_restore_backup_agent_error( expected_reason: str, ) -> None: """Test restore backup with agent error.""" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -3128,7 +3129,7 @@ async def test_restore_backup_file_error( validate_password_call_count: int, ) -> None: """Test restore backup with file error.""" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -3346,7 +3347,7 @@ async def test_initiate_backup_per_agent_encryption( ) -> None: """Test generate backup where encryption is selectively set on agents.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 773256bdd0b..e97183fc53f 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -34,7 +34,7 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, - BackupAgentTest, + mock_backup_agent, setup_backup_integration, setup_backup_platform, ) @@ -112,9 +112,9 @@ def mock_get_backups() -> Generator[AsyncMock]: ("remote_agents", "remote_backups"), [ ([], {}), - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ], ) async def test_info( @@ -153,25 +153,26 @@ async def test_info_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test") + mock_agent.async_list_backups.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/info"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -215,15 +216,14 @@ async def test_details_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test") + mock_agent.async_get_backup.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect), - ): + with patch("pathlib.Path.exists", return_value=True): await client.send_json_auto_id( {"type": "backup/details", "backup_id": "abc123"} ) @@ -234,11 +234,11 @@ async def test_details_with_errors( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -307,14 +307,15 @@ async def test_delete_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test", [TEST_BACKUP_ABC123]) + mock_agent.async_delete_backup.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot await client.send_json_auto_id({"type": "backup/info"}) assert await client.receive_json() == snapshot @@ -327,21 +328,21 @@ async def test_agent_delete_backup( ) -> None: """Test deleting a backup file with a mock agent.""" await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + mock_agent = mock_backup_agent("test") + hass.data[DATA_MANAGER].backup_agents = {"domain.test": mock_agent} client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock: - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": "abc123", - } - ) - assert await client.receive_json() == snapshot + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot - assert delete_mock.call_args == call("abc123") + assert mock_agent.async_delete_backup.call_args == call("abc123") @pytest.mark.parametrize( @@ -588,7 +589,7 @@ async def test_generate_with_default_settings_calls_create( client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -688,8 +689,8 @@ async def test_restore_local_agent( @pytest.mark.parametrize( ("remote_agents", "backups"), [ - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), ], ) async def test_restore_remote_agent( @@ -700,6 +701,7 @@ async def test_restore_remote_agent( snapshot: SnapshotAssertion, ) -> None: """Test calling the restore command.""" + await setup_backup_integration( hass, with_hassio=False, backups=backups, remote_agents=remote_agents ) @@ -892,7 +894,7 @@ async def test_agents_info( ) -> None: """Test getting backup agents info.""" await setup_backup_integration(hass, with_hassio=False) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_backup_agent("test") client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -1730,7 +1732,7 @@ async def test_config_schedule_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test.test-agent"]) await hass.async_block_till_done() for command in commands: @@ -1773,7 +1775,7 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "agent_delete_backup_side_effects", + "delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", @@ -2345,7 +2347,7 @@ async def test_config_retention_copies_logic( command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - agent_delete_backup_side_effects: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, @@ -2392,14 +2394,13 @@ async def test_config_retention_copies_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent", "test.test-agent2"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent_id, agent in manager.backup_agents.items(): - agent.async_delete_backup = AsyncMock( - side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True - ) + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) await client.send_json_auto_id(command) result = await client.receive_json() @@ -2411,7 +2412,7 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -2671,13 +2672,11 @@ async def test_config_retention_copies_logic_manual_backup( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent in manager.backup_agents.values(): - agent.async_delete_backup = AsyncMock(autospec=True) - await client.send_json_auto_id(config_command) result = await client.receive_json() assert result["success"] @@ -2692,7 +2691,7 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -2714,7 +2713,7 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "agent_delete_backup_side_effects", + "delete_backup_side_effects", "last_backup_time", "start_time", "next_time", @@ -3077,7 +3076,7 @@ async def test_config_retention_days_logic( commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - agent_delete_backup_side_effects: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, @@ -3120,14 +3119,13 @@ async def test_config_retention_days_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent_id, agent in manager.backup_agents.items(): - agent.async_delete_backup = AsyncMock( - side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True - ) + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) for command in commands: await client.send_json_auto_id(command) @@ -3138,7 +3136,7 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -3222,21 +3220,21 @@ async def test_can_decrypt_on_download_with_agent_error( ) -> None: """Test can decrypt on download.""" - await setup_backup_integration( + mock_agents = await setup_backup_integration( hass, with_hassio=False, backups={"test.remote": [TEST_BACKUP_ABC123]}, - remote_agents=["remote"], + remote_agents=["test.remote"], ) client = await hass_ws_client(hass) - with patch.object(BackupAgentTest, "async_download_backup", side_effect=error): - await client.send_json_auto_id( - { - "type": "backup/can_decrypt_on_download", - "backup_id": TEST_BACKUP_ABC123.backup_id, - "agent_id": "test.remote", - "password": "hunter2", - } - ) - assert await client.receive_json() == snapshot + mock_agents["test.remote"].async_download_backup.side_effect = error + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot From 2033dbdd9016fed910f636cc2234d11257d923a7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 09:22:35 +0100 Subject: [PATCH 015/155] Use entry.async_on_unload in fireservicerota (#138360) --- homeassistant/components/fireservicerota/__init__.py | 5 +---- homeassistant/components/fireservicerota/coordinator.py | 4 ++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index bf5385b6f2a..6f48dcfc4bc 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if client.token_refresh_failure: return False + entry.async_on_unload(client.async_stop_listener) coordinator = FireServiceUpdateCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -43,10 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload FireServiceRota config entry.""" - - await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener - ) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index 14a8c40e469..c452421d57b 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -213,3 +213,7 @@ class FireServiceRotaClient: ) await self.update_call(self.fsr.set_incident_response, self.incident_id, value) + + async def async_stop_listener(self) -> None: + """Stop listener.""" + await self._hass.async_add_executor_job(self.websocket.stop_listener) From 6ef1178a35e54e10ebc94bfb9131db671df0dcba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 09:49:01 +0100 Subject: [PATCH 016/155] Use setup_backup_integration test helper in backup tests (#138362) --- tests/components/backup/common.py | 18 +- .../backup/snapshots/test_websocket.ambr | 40 +- tests/components/backup/test_http.py | 73 ++-- tests/components/backup/test_manager.py | 389 ++++-------------- tests/components/backup/test_websocket.py | 79 ++-- 5 files changed, 158 insertions(+), 441 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index b4ebfd70fcd..b21698bf365 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -20,7 +20,7 @@ from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import mock_platform LOCAL_AGENT_ID = f"{DOMAIN}.local" @@ -138,15 +138,15 @@ async def setup_backup_integration( raise ValueError(f"Invalid agent_id: {agent}") name = agent.partition(".")[2] remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent)) - platform = Mock( - async_get_backup_agents=AsyncMock( - return_value=list(remote_agents_dict.values()) - ), - spec_set=BackupAgentPlatformProtocol, - ) + if remote_agents: + platform = Mock( + async_get_backup_agents=AsyncMock( + return_value=list(remote_agents_dict.values()) + ), + spec_set=BackupAgentPlatformProtocol, + ) + await setup_backup_platform(hass, domain=TEST_DOMAIN, platform=platform) - mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) - assert await async_setup_component(hass, TEST_DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 9236a0cbe0f..4452d191d5a 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -16,12 +16,12 @@ 'result': dict({ 'agents': list([ dict({ - 'agent_id': 'backup.local', - 'name': 'local', + 'agent_id': 'test.remote', + 'name': 'remote', }), dict({ - 'agent_id': 'test.test', - 'name': 'test', + 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -3457,7 +3457,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -3480,7 +3480,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3520,7 +3520,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -3543,7 +3543,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3606,7 +3606,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3668,7 +3668,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3708,7 +3708,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), }), 'success': True, @@ -3731,7 +3731,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3771,7 +3771,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), }), 'success': True, @@ -3794,7 +3794,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3992,7 +3992,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backup': dict({ 'addons': list([ @@ -4036,7 +4036,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Oops', + 'test.remote': 'Oops', }), 'backup': dict({ 'addons': list([ @@ -4080,7 +4080,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), 'backup': dict({ 'addons': list([ @@ -4584,7 +4584,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backups': list([ dict({ @@ -4636,7 +4636,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Oops', + 'test.remote': 'Oops', }), 'backups': list([ dict({ @@ -4688,7 +4688,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), 'backups': list([ dict({ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a2f32d93fc3..a03217beac2 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -18,19 +18,28 @@ from homeassistant.components.backup import ( BackupNotFound, Folder, ) -from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant -from .common import ( - TEST_BACKUP_ABC123, - aiter_from_iter, - mock_backup_agent, - setup_backup_integration, -) +from .common import TEST_BACKUP_ABC123, aiter_from_iter, setup_backup_integration from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator +PROTECTED_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="c0cb53bd", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, +) + async def test_downloading_local_backup( hass: HomeAssistant, @@ -115,32 +124,15 @@ async def test_downloading_remote_encrypted_backup( ) -> None: """Test downloading a local backup file.""" backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) - await setup_backup_integration(hass) - mock_agent = mock_backup_agent( - "test", - [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="c0cb53bd", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=13, - ) - ], + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: return aiter_from_iter((backup_path.read_bytes(),)) - mock_agent.async_download_backup.side_effect = download_backup - await _test_downloading_encrypted_backup(hass_client, "domain.test") + mock_agents["test.test"].async_download_backup.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "test.test") @pytest.mark.parametrize( @@ -157,31 +149,14 @@ async def test_downloading_remote_encrypted_backup_with_error( status: int, ) -> None: """Test downloading a local backup file.""" - await setup_backup_integration(hass) - mock_agent = mock_backup_agent( - "test", - [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="abc123", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=13, - ) - ], + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent - mock_agent.async_download_backup.side_effect = error + mock_agents["test.test"].async_download_backup.side_effect = error client = await hass_client() resp = await client.get( - "/api/backup/download/abc123?agent_id=domain.test&password=blah" + f"/api/backup/download/{PROTECTED_BACKUP.backup_id}?agent_id=test.test&password=blah" ) assert resp.status == status diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2c01774531..b2b7e083a51 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -27,11 +27,9 @@ import pytest from homeassistant.components.backup import ( DOMAIN, AgentBackup, - BackupAgentPlatformProtocol, BackupReaderWriterError, Folder, LocalBackupAgent, - backup as local_backup_platform, ) from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER @@ -50,7 +48,6 @@ from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -59,6 +56,7 @@ from .common import ( TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456, mock_backup_agent, + setup_backup_integration, setup_backup_platform, ) @@ -110,8 +108,7 @@ async def test_create_backup_service( mocked_tarfile: Mock, ) -> None: """Test create backup service.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) new_backup = NewBackup(backup_job_id="time-123") backup_task = AsyncMock( @@ -307,8 +304,7 @@ async def test_async_create_backup( expected_writer_kwargs: dict[str, Any], ) -> None: """Test create backup.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) manager = hass.data[DATA_MANAGER] new_backup = NewBackup(backup_job_id="time-123") @@ -336,8 +332,7 @@ async def test_create_backup_when_busy( hass_ws_client: WebSocketGenerator, ) -> None: """Test generate backup with busy manager.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id( @@ -385,8 +380,7 @@ async def test_create_backup_wrong_parameters( expected_error: str, ) -> None: """Test create backup with wrong parameters.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) @@ -523,23 +517,7 @@ async def test_initiate_backup( temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") @@ -693,7 +671,6 @@ async def test_initiate_backup_with_agent_error( ) -> None: """Test agent upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id @@ -771,22 +748,12 @@ async def test_initiate_backup_with_agent_error( "with_automatic_settings": True, }, ] - remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) ws_client = await hass_ws_client(hass) @@ -821,17 +788,8 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["success"] is True - delete_backup = AsyncMock() - - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - patch.object(remote_agent, "async_delete_backup", delete_backup), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) @@ -922,7 +880,7 @@ async def test_initiate_backup_with_agent_error( ] # one of the two matching backups with the remote agent should have been deleted - assert delete_backup.call_count == 1 + assert mock_agents["test.remote"].async_delete_backup.call_count == 1 @pytest.mark.usefixtures("mock_backup_generation") @@ -946,8 +904,7 @@ async def test_create_backup_success_clears_issue( issues_after_create_backup: set[tuple[str, str]], ) -> None: """Test backup issue is cleared after backup is created.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) # Create a backup issue ir.async_create_issue( @@ -996,7 +953,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: "automatic_agents", "create_backup_command", "create_backup_side_effect", - "agent_upload_side_effect", + "upload_side_effect", "create_backup_result", "issues_after_create_backup", ), @@ -1115,26 +1072,12 @@ async def test_create_backup_failure_raises_issue( automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, - agent_upload_side_effect: Exception | None, + upload_side_effect: Exception | None, create_backup_result: bool, issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: """Test backup issue is cleared after backup is created.""" - remote_agent = mock_backup_agent("remote") - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1149,13 +1092,11 @@ async def test_create_backup_failure_raises_issue( result = await ws_client.receive_json() assert result["success"] is True - with patch.object( - remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect - ): - await ws_client.send_json_auto_id(create_backup_command) - result = await ws_client.receive_json() - assert result["success"] == create_backup_result - await hass.async_block_till_done() + mock_agents["test.remote"].async_upload_backup.side_effect = upload_side_effect + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] == create_backup_result + await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert set(issue_registry.issues) == set(issues_after_create_backup) @@ -1179,23 +1120,7 @@ async def test_initiate_backup_non_agent_upload_error( ) -> None: """Test an unknown or writer upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1224,14 +1149,8 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["success"] is True - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) @@ -1297,23 +1216,8 @@ async def test_initiate_backup_with_task_error( backup_task.set_exception(exception) create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1408,23 +1312,8 @@ async def test_initiate_backup_file_error( ) -> None: """Test file error during generate backup.""" agent_ids = ["test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1533,7 +1422,7 @@ async def test_loading_platform_with_listener( ) -> None: """Test loading a backup agent platform which can be listened to.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await setup_backup_integration(hass) manager = hass.data[DATA_MANAGER] get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")]) @@ -1593,8 +1482,7 @@ async def test_not_loading_bad_platforms( domain="test", platform=platform_mock, ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) assert platform_mock.mock_calls == [] @@ -1615,8 +1503,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) with pytest.raises(BackupManagerError) as err: await hass.services.async_call( @@ -1645,8 +1532,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) with pytest.raises(BackupManagerError) as err: await hass.services.async_call( @@ -1710,17 +1596,7 @@ async def test_receive_backup( temp_file_unlink_call_count: int, ) -> None: """Test receive backup and upload to the local and a remote agent.""" - remote_agent = mock_backup_agent("remote") - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() upload_data = "test" @@ -1750,6 +1626,7 @@ async def test_receive_backup( assert move_mock.call_count == move_call_count for index, name in enumerate(move_path_names): assert move_mock.call_args_list[index].args[1].name == name + remote_agent = mock_agents["test.remote"] for backup_id, (backup, expected_backup_data) in remote_agent_backups.items(): assert await remote_agent.async_get_backup(backup_id) == backup backup_data = bytearray() @@ -1770,8 +1647,7 @@ async def test_receive_backup_busy_manager( new_backup = NewBackup(backup_job_id="time-123") backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() create_backup.return_value = (new_backup, backup_task) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -1833,7 +1709,6 @@ async def test_receive_backup_agent_error( exception: Exception, ) -> None: """Test upload error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id @@ -1911,22 +1786,12 @@ async def test_receive_backup_agent_error( "with_automatic_settings": True, }, ] - remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -1962,13 +1827,11 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["success"] is True - delete_backup = AsyncMock() upload_data = "test" open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + mock_agents["test.remote"].async_upload_backup.side_effect = exception with ( - patch.object(remote_agent, "async_delete_backup", delete_backup), - patch.object(remote_agent, "async_upload_backup", side_effect=exception), patch("pathlib.Path.open", open_mock), patch("shutil.move") as move_mock, patch( @@ -2050,7 +1913,7 @@ async def test_receive_backup_agent_error( assert open_mock.call_count == 1 assert move_mock.call_count == 0 assert unlink_mock.call_count == 1 - assert delete_backup.call_count == 0 + assert mock_agents["test.remote"].async_delete_backup.call_count == 0 @pytest.mark.usefixtures("mock_backup_generation") @@ -2064,23 +1927,7 @@ async def test_receive_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test non agent upload error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2113,8 +1960,8 @@ async def test_receive_backup_non_agent_upload_error( upload_data = "test" open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + mock_agents["test.remote"].async_upload_backup.side_effect = exception with ( - patch.object(remote_agent, "async_upload_backup", side_effect=exception), patch("pathlib.Path.open", open_mock), patch("shutil.move") as move_mock, patch( @@ -2192,22 +2039,7 @@ async def test_receive_backup_file_write_error( close_exception: Exception | None, ) -> None: """Test file write error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2303,22 +2135,7 @@ async def test_receive_backup_read_tar_error( exception: Exception, ) -> None: """Test read tar error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2483,23 +2300,7 @@ async def test_receive_backup_file_read_error( response_status: int, ) -> None: """Test file read error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2655,16 +2456,10 @@ async def test_restore_backup( ) -> None: """Test restore backup.""" password = password_param.get("password") - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -2685,13 +2480,11 @@ async def test_restore_backup( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", side_effect=mock_read_backup, ), ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", @@ -2762,16 +2555,10 @@ async def test_restore_backup_wrong_password( ) -> None: """Test restore backup wrong password.""" password = "hunter2" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -2792,13 +2579,11 @@ async def test_restore_backup_wrong_password( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", side_effect=mock_read_backup, ), ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) validate_password_mock.return_value = False await ws_client.send_json_auto_id( { @@ -2872,8 +2657,7 @@ async def test_restore_backup_wrong_parameters( expected_reason: str, ) -> None: """Test restore backup wrong parameters.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) @@ -2937,8 +2721,7 @@ async def test_restore_backup_when_busy( hass_ws_client: WebSocketGenerator, ) -> None: """Test restore backup with busy manager.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id( @@ -2989,16 +2772,10 @@ async def test_restore_backup_agent_error( expected_reason: str, ) -> None: """Test restore backup with agent error.""" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + mock_agents = await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -3010,19 +2787,17 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["success"] is True + mock_agents["test.remote"].async_download_backup.side_effect = exception with ( patch("pathlib.Path.open"), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - patch.object( - remote_agent, "async_download_backup", side_effect=exception - ) as download_mock, ): await ws_client.send_json_auto_id( { "type": "backup/restore", "backup_id": TEST_BACKUP_ABC123.backup_id, - "agent_id": remote_agent.agent_id, + "agent_id": "test.remote", } ) @@ -3050,7 +2825,7 @@ async def test_restore_backup_agent_error( assert result["error"]["code"] == error_code assert result["error"]["message"] == error_message - assert download_mock.call_count == 1 + assert mock_agents["test.remote"].async_download_backup.call_count == 1 assert mocked_write_text.call_count == 0 assert mocked_service_call.call_count == 0 @@ -3129,16 +2904,10 @@ async def test_restore_backup_file_error( validate_password_call_count: int, ) -> None: """Test restore backup with file error.""" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + mock_agents = await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -3164,14 +2933,12 @@ async def test_restore_backup_file_error( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", "backup_id": TEST_BACKUP_ABC123.backup_id, - "agent_id": remote_agent.agent_id, + "agent_id": "test.remote", } ) @@ -3199,7 +2966,7 @@ async def test_restore_backup_file_error( assert result["error"]["code"] == "unknown_error" assert result["error"]["message"] == "Unknown error" - assert download_mock.call_count == 1 + assert mock_agents["test.remote"].async_download_backup.call_count == 1 assert validate_password_mock.call_count == validate_password_call_count assert open_mock.call_count == open_call_count assert open_mock.return_value.write.call_count == write_call_count @@ -3346,23 +3113,7 @@ async def test_initiate_backup_per_agent_encryption( inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -3512,8 +3263,7 @@ async def test_restore_progress_after_restart( with patch( "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id({"type": "backup/info"}) @@ -3539,8 +3289,7 @@ async def test_restore_progress_after_restart_fail_to_remove( """Test restore backup progress after restart when failing to remove result file.""" with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id({"type": "backup/info"}) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e97183fc53f..8632fb1e957 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -11,7 +11,6 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import ( AgentBackup, BackupAgentError, - BackupAgentPlatformProtocol, BackupNotFound, BackupReaderWriterError, Folder, @@ -28,15 +27,12 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, - mock_backup_agent, setup_backup_integration, - setup_backup_platform, ) from tests.common import async_fire_time_changed, async_mock_service @@ -150,12 +146,13 @@ async def test_info_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test") - mock_agent.async_list_backups.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_list_backups.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -213,12 +210,13 @@ async def test_details_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test") - mock_agent.async_get_backup.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_get_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -304,12 +302,16 @@ async def test_delete_with_errors( "version": store.STORAGE_VERSION, "minor_version": store.STORAGE_VERSION_MINOR, } - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={ + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test", [TEST_BACKUP_ABC123]) - mock_agent.async_delete_backup.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_delete_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -327,9 +329,9 @@ async def test_agent_delete_backup( snapshot: SnapshotAssertion, ) -> None: """Test deleting a backup file with a mock agent.""" - await setup_backup_integration(hass) - mock_agent = mock_backup_agent("test") - hass.data[DATA_MANAGER].backup_agents = {"domain.test": mock_agent} + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -342,7 +344,7 @@ async def test_agent_delete_backup( ) assert await client.receive_json() == snapshot - assert mock_agent.async_delete_backup.call_args == call("abc123") + assert mock_agents["test.remote"].async_delete_backup.call_args == call("abc123") @pytest.mark.parametrize( @@ -589,17 +591,9 @@ async def test_generate_with_default_settings_calls_create( client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") - remote_agent = mock_backup_agent("remote") - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] ) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() await client.send_json_auto_id( {"type": "backup/config/update", "create_backup": create_backup_settings} @@ -624,15 +618,13 @@ async def test_generate_with_default_settings_calls_create( is None ) - with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect): - await client.send_json_auto_id( - {"type": "backup/generate_with_automatic_settings"} - ) - result = await client.receive_json() - assert result["success"] - assert result["result"] == {"backup_job_id": "abc123"} + mock_agents["test.remote"].async_upload_backup.side_effect = side_effect + await client.send_json_auto_id({"type": "backup/generate_with_automatic_settings"}) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} - await hass.async_block_till_done() + await hass.async_block_till_done() create_backup.assert_called_once_with(**expected_call_params) @@ -893,8 +885,9 @@ async def test_agents_info( snapshot: SnapshotAssertion, ) -> None: """Test getting backup agents info.""" - await setup_backup_integration(hass, with_hassio=False) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_backup_agent("test") + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() From a3cde3d8abee9bdafbe03552f0bcde8a397e4600 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 12 Feb 2025 22:22:58 +1100 Subject: [PATCH 017/155] Fix authentication error when adding new devices to SMLIGHT (#138373) * Fix authentication issue Fixes #138216 * Fix incorrect mocks in unsupported device tests * set _device_name in auth flow also * Update get_info Mock to handle authentication * Update tests --- .../components/smlight/config_flow.py | 12 +++-- tests/components/smlight/conftest.py | 14 ++++-- tests/components/smlight/test_button.py | 7 ++- tests/components/smlight/test_config_flow.py | 50 +++++++++++++++++-- tests/components/smlight/test_init.py | 1 + tests/components/smlight/test_update.py | 5 ++ 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 667e6e2884b..fcfc364d983 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -77,12 +77,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - info = await self.client.get_info() - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 0b1bf24c19a..7a1b16f1d6b 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight.exceptions import SmlightAuthError from pysmlight.sse import sseClient from pysmlight.web import CmdWrapper, Firmware, Info, Sensors import pytest @@ -81,9 +82,16 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: ): api = smlight_mock.return_value api.host = MOCK_HOST - api.get_info.return_value = Info.from_dict( - load_json_object_fixture("info.json", DOMAIN) - ) + + def get_info_side_effect(*args, **kwargs) -> Info: + """Return the info.""" + if api.check_auth_needed.return_value and not api.authenticate.called: + raise SmlightAuthError + + return Info.from_dict(load_json_object_fixture("info.json", DOMAIN)) + + api.get_info.side_effect = get_info_side_effect + api.get_sensors.return_value = Sensors.from_dict( load_json_object_fixture("sensors.json", DOMAIN) ) diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index 3721ee815e6..51e9414c00e 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -45,6 +45,7 @@ async def test_buttons( mock_smlight_client: MagicMock, ) -> None: """Test creation of button entities.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -78,6 +79,7 @@ async def test_disabled_by_default_buttons( mock_smlight_client: MagicMock, ) -> None: """Test the disabled by default buttons.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -96,7 +98,8 @@ async def test_remove_router_reconnect( mock_smlight_client: MagicMock, ) -> None: """Test removal of orphaned router reconnect button.""" - save_mock = mock_smlight_client.get_info.return_value + save_mock = mock_smlight_client.get_info.side_effect + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER mock_config_entry = await setup_integration(hass, mock_config_entry) @@ -106,7 +109,7 @@ async def test_remove_router_reconnect( assert len(entities) == 4 assert entities[3].unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router" - mock_smlight_client.get_info.return_value = save_mock + mock_smlight_client.get_info.side_effect = save_mock freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index a1c9c9d6945..c8933029ce6 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -66,6 +66,46 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_auth( + hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full manual user flow with authentication.""" + + mock_smlight_client.check_auth_needed.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "slzb-06p7.local", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "auth" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "SLZB-06p7" + assert result3["data"] == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } + assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, @@ -145,7 +185,7 @@ async def test_zeroconf_flow_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["context"]["source"] == "zeroconf" assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result3["title"] == "slzb-06" + assert result3["title"] == "SLZB-06p7" assert result3["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, @@ -162,6 +202,7 @@ async def test_zeroconf_unsupported_abort( mock_smlight_client: MagicMock, ) -> None: """Test we abort zeroconf flow if device unsupported.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info(model="SLZB-X") result = await hass.config_entries.flow.async_init( @@ -186,6 +227,7 @@ async def test_user_unsupported_abort( mock_smlight_client: MagicMock, ) -> None: """Test we abort user flow if unsupported device.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info(model="SLZB-X") result = await hass.config_entries.flow.async_init( @@ -206,15 +248,13 @@ async def test_user_unsupported_abort( assert result2["reason"] == "unsupported_device" -async def test_user_unsupported_abort_auth( +async def test_user_unsupported_device_abort_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, ) -> None: """Test we abort user flow if unsupported device (with auth).""" mock_smlight_client.check_auth_needed.return_value = True - mock_smlight_client.authenticate.side_effect = SmlightAuthError - mock_smlight_client.get_info.side_effect = SmlightAuthError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -366,7 +406,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 4 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_user_cannot_connect( diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index 0acbab9f3a4..692255a53e6 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -165,6 +165,7 @@ async def test_device_legacy_firmware( """Test device setup for old firmware version that dont support required API.""" LEGACY_VERSION = "v0.9.9" mock_smlight_client.get_sensors.side_effect = SmlightError + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( legacy_api=2, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" ) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 632f1b5f26b..86d19968910 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -132,6 +132,7 @@ async def test_update_firmware( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.7.5", ) @@ -153,6 +154,7 @@ async def test_update_zigbee2_firmware( mock_smlight_client: MagicMock, ) -> None: """Test update of zigbee2 firmware where available.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info.from_dict( load_json_object_fixture("info-MR1.json", DOMAIN) ) @@ -195,6 +197,7 @@ async def test_update_legacy_firmware_v2( mock_smlight_client: MagicMock, ) -> None: """Test firmware update for legacy v2 firmware.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.0.18", legacy_api=1, @@ -220,6 +223,7 @@ async def test_update_legacy_firmware_v2( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.7.5", ) @@ -333,6 +337,7 @@ async def test_update_release_notes( hass_ws_client: WebSocketGenerator, ) -> None: """Test firmware release notes.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info.from_dict( load_json_object_fixture("info-MR1.json", DOMAIN) ) From 487a4ac5c4305863a2c888f52dfd8acee975330e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Feb 2025 12:28:15 +0100 Subject: [PATCH 018/155] Improve field names and descriptions of easyEnergy actions (#138319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/easyenergy/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 96afffdf78f..502db7920a3 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -60,12 +60,12 @@ "description": "Requests gas prices from easyEnergy.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The configuration entry to use for this action." }, "incl_vat": { - "name": "VAT Included", - "description": "Include or exclude VAT in the prices, default is true." + "name": "VAT included", + "description": "Whether the prices should include VAT." }, "start": { "name": "Start", From 88b444fa5ba5d5789dde90ce3343bc8153244525 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Wed, 12 Feb 2025 12:35:36 +0100 Subject: [PATCH 019/155] Add Homee sensor tests (#137200) --- tests/components/homee/__init__.py | 9 + tests/components/homee/fixtures/sensors.json | 715 +++++++ .../homee/snapshots/test_sensor.ambr | 1811 +++++++++++++++++ tests/components/homee/test_sensor.py | 124 ++ 4 files changed, 2659 insertions(+) create mode 100644 tests/components/homee/fixtures/sensors.json create mode 100644 tests/components/homee/snapshots/test_sensor.ambr create mode 100644 tests/components/homee/test_sensor.py diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index a5f8ae00d1e..432e2d68516 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -49,3 +49,12 @@ def build_mock_node(file: str) -> AsyncMock: mock_node.get_attribute_by_type = attribute_by_type return mock_node + + +async def async_update_attribute_value( + hass: HomeAssistant, attribute: AsyncMock, value: float +) -> None: + """Set the current_value of an attribute and notify hass.""" + attribute.current_value = value + attribute.add_on_changed_listener.call_args_list[0][0][0](attribute) + await hass.async_block_till_done() diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json new file mode 100644 index 00000000000..f4a7f462218 --- /dev/null +++ b/tests/components/homee/fixtures/sensors.json @@ -0,0 +1,715 @@ +{ + "id": 1, + "name": "Test MultiSensor", + "profile": 4010, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 200000, + "current_value": 555.591, + "target_value": 555.591, + "last_value": 555.586, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 200000, + "current_value": 1730.812, + "target_value": 1730.812, + "last_value": 1730.679, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 65000, + "current_value": 175.0, + "target_value": 175.0, + "last_value": 66.0, + "unit": "lx", + "step_value": 1.0, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 1, + "maximum": 100, + "current_value": 7.0, + "target_value": 7.0, + "last_value": 8.0, + "unit": "klx", + "step_value": 0.5, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1700056686, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 70, + "current_value": 0.249, + "target_value": 0.249, + "last_value": 0.249, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 70, + "current_value": 0.812, + "target_value": 0.812, + "last_value": 0.252, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 500, + "current_value": 500.0, + "target_value": 500.0, + "last_value": 500.0, + "unit": "lx", + "step_value": 2.0, + "editable": 0, + "type": 301, + "state": 1, + "last_changed": 1700056347, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -40, + "maximum": 100, + "current_value": 44.12, + "target_value": 44.12, + "last_value": 44.27, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 92, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 2000.0, + "target_value": 0.0, + "last_value": 1800.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 103, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 47.0, + "target_value": 47.0, + "last_value": 47.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 96, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 18.0, + "target_value": 18.0, + "last_value": 18.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 98, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 102, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 99999, + "current_value": 2490.0, + "target_value": 2490.0, + "last_value": 2516.0, + "unit": "L", + "step_value": 1.0, + "editable": 0, + "type": 22, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 4.0, + "target_value": 4.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 33, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 196605, + "current_value": 5478.0, + "target_value": 5478.0, + "last_value": 5478.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 104, + "state": 1, + "last_changed": 1736105231, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 33.0, + "target_value": 33.0, + "last_value": 32.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 95, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 17.0, + "target_value": 17.0, + "last_value": 17.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 97, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 21, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 15, + "state": 1, + "last_changed": 1694176210, + "changed_by": 2, + "changed_by_id": 2, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 51.0, + "target_value": 51.0, + "last_value": 51.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 7, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 20.3, + "target_value": 20.3, + "last_value": 20.3, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 600000, + "current_value": 3657.822, + "target_value": 3657.822, + "last_value": 3657.377, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 240, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 200, + "current_value": 2.223, + "target_value": 2.223, + "last_value": 2.21, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 272, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 80000, + "current_value": 195.384, + "target_value": 195.384, + "last_value": 248.412, + "unit": "W", + "step_value": 1.0, + "editable": 0, + "type": 239, + "state": 1, + "last_changed": 1694176076, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 235.775, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 51, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 3.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 15, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 6.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 173, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 30, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 239.559, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 31, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 420, + "current_value": 236.867, + "target_value": 236.867, + "last_value": 237.634, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 32, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 25, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.5, + "unit": "m/s", + "step_value": 1.0, + "editable": 0, + "type": 146, + "state": 1, + "last_changed": 1700056836, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 33, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 10, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3101723232e --- /dev/null +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -0,0 +1,1811 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_multisensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_current_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.249', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_dawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dawn', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dawn', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Dawn', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_dawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_temperature', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Device temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.12', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_energy_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555.591', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_energy_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1730.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_motor_revs', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Exhaust motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 1', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '175.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 2', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_indoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_humidity', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Indoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_indoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Indoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intake motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intake_motor_revs', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Intake motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'level', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Test MultiSensor Level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2490.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_link_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link quality', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Node state', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'node_state', + 'unique_id': '00055511EECC-1-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Node state', + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operating hours', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_hours', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test MultiSensor Operating hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5478.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_humidity', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'position', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_down', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor State', + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total current', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_current', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Total current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.223', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3657.822', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test MultiSensor Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '195.384', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total voltage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_voltage', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Total voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_ultraviolet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ultraviolet', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Ultraviolet', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_ultraviolet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_voltage_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-30', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-31', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.867', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': '00055511EECC-1-32', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test MultiSensor Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_window_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'window_position', + 'unique_id': '00055511EECC-1-33', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Window position', + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_window_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py new file mode 100644 index 00000000000..8ee48d3ea97 --- /dev/null +++ b/tests/components/homee/test_sensor.py @@ -0,0 +1,124 @@ +"""Test homee sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homee.const import ( + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from homeassistant.const import LIGHT_LUX +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_update_attribute_value, build_mock_node, setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_up_down_values( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for up/down sensor.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] + + attribute = mock_homee.nodes[0].attributes[27] + for i in range(1, 5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[i] + ) + + # Test reversed up/down sensor + attribute.is_reversed = True + for i in range(5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state + == OPEN_CLOSE_MAP_REVERSED[i] + ) + + +async def test_window_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for window handle position.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[0] + ) + + attribute = mock_homee.nodes[0].attributes[32] + for i in range(1, 3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[i] + ) + + # Test reversed window handle. + attribute.is_reversed = True + for i in range(3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP_REVERSED[i] + ) + + +async def test_brightness_sensor( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + + sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") + assert sensor_state.state == "175.0" + assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX + assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" + + # Sensor with Homee unit klx + sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") + assert sensor_state.state == "7000.0" + assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX + assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + entity_registry.async_update_entity( + "sensor.test_multisensor_node_state", disabled_by=None + ) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 64fa9b78f8114eee47518471010c22ecc020c18e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Feb 2025 12:39:43 +0100 Subject: [PATCH 020/155] Fix typos in user-facing strings of Bayesian integration (#138364) --- homeassistant/components/bayesian/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json index 9ebccedc88d..00de79a2229 100644 --- a/homeassistant/components/bayesian/strings.json +++ b/homeassistant/components/bayesian/strings.json @@ -5,14 +5,14 @@ "title": "Manual YAML fix required for Bayesian" }, "no_prob_given_false": { - "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.", + "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yaml` for `bayesian/{entity}`. These observations will be ignored until you do.", "title": "Manual YAML addition required for Bayesian" } }, "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads bayesian sensors from the YAML-configuration." + "description": "Reloads Bayesian sensors from the YAML-configuration." } } } From f1471f143c02ffd92e0c467f8c335575a86c3e87 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 03:41:52 -0800 Subject: [PATCH 021/155] Fix broken issue creation in econet (#137773) * econet: Fix broken issue creation * econet: fix broken issue creation with create_issue --- homeassistant/components/econet/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index b9673869046..cb2374bd69b 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry from .const import DOMAIN @@ -209,7 +209,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", @@ -223,7 +223,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", From 2bb582f8e6a3988640074c75f212e5c8f63bb05a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:42:22 +0100 Subject: [PATCH 022/155] Use runtime_data in geo_json_events (#138366) * Use runtime_data in geo_json_events * Update __init__.py --- .../components/geo_json_events/__init__.py | 22 +++++++++---------- .../geo_json_events/geo_location.py | 15 ++++--------- .../components/geo_json_events/manager.py | 2 ++ tests/components/geo_json_events/conftest.py | 2 +- .../geo_json_events/test_config_flow.py | 2 +- tests/components/geo_json_events/test_init.py | 6 ++--- 6 files changed, 21 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index d55fe6e3ee6..e38c17008a5 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -4,25 +4,27 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS -from .manager import GeoJsonFeedEntityManager +from .const import PLATFORMS +from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: GeoJsonConfigEntry +) -> bool: """Set up the GeoJSON events component as config entry.""" - feeds = hass.data.setdefault(DOMAIN, {}) # Create feed entity manager for all platforms. manager = GeoJsonFeedEntityManager(hass, config_entry) - feeds[config_entry.entry_id] = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await remove_orphaned_entities(hass, config_entry.entry_id) + + config_entry.runtime_data = manager + config_entry.async_on_unload(manager.async_stop) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await manager.async_init() return True @@ -46,10 +48,6 @@ async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: entity_registry.async_remove(entry.entity_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GeoJsonConfigEntry) -> bool: """Unload the GeoJSON events config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id) - await manager.async_stop() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index dce4aac1630..a119571a0ca 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -9,31 +9,24 @@ from typing import Any from aio_geojson_generic_client.feed_entry import GenericFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GeoJsonFeedEntityManager -from .const import ( - ATTR_EXTERNAL_ID, - DOMAIN, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, - SOURCE, -) +from .const import ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE +from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeoJsonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoJSON Events platform.""" - manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index deff15436a6..223d3bf571f 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -25,6 +25,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type GeoJsonConfigEntry = ConfigEntry[GeoJsonFeedEntityManager] + class GeoJsonFeedEntityManager: """Feed Entity Manager for GeoJSON feeds.""" diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py index 11928e6f012..a4fff4563be 100644 --- a/tests/components/geo_json_events/conftest.py +++ b/tests/components/geo_json_events/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL from tests.common import MockConfigEntry diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index fe21bccc7aa..9a52cb599b2 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -3,7 +3,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py index e90e663d8b6..0553190395d 100644 --- a/tests/components/geo_json_events/test_init.py +++ b/tests/components/geo_json_events/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,11 +24,11 @@ async def test_component_unload_config_entry( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][config_entry.entry_id] is not None + assert config_entry.state is ConfigEntryState.LOADED # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN].get(config_entry.entry_id) is None + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_remove_orphaned_entities( From ef9d5dd568bca77fe6127ab9d7a624529b772846 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 12:46:53 +0100 Subject: [PATCH 023/155] Bump cryptography to 44.0.1 (#138371) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f52f49ce09..43752fb558b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.0 +cryptography==44.0.1 dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 diff --git a/pyproject.toml b/pyproject.toml index 3936fdb3a1e..11bda6bf1fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.0", + "cryptography==44.0.1", "Pillow==11.1.0", "propcache==0.2.1", "pyOpenSSL==25.0.0", diff --git a/requirements.txt b/requirements.txt index f0ff3b8054a..fcfdded632b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 -cryptography==44.0.0 +cryptography==44.0.1 Pillow==11.1.0 propcache==0.2.1 pyOpenSSL==25.0.0 From e12b100a3743f4405f22284c3bef58daed7d8ded Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:49:26 +0100 Subject: [PATCH 024/155] Use runtime_data in fireservicerota (#138361) --- .../components/fireservicerota/__init__.py | 26 ++++++++----------- .../fireservicerota/binary_sensor.py | 18 ++++++------- .../components/fireservicerota/coordinator.py | 9 ++++++- .../components/fireservicerota/sensor.py | 11 +++----- .../components/fireservicerota/switch.py | 15 ++++++----- 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index 6f48dcfc4bc..0f30a29cfba 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -4,23 +4,23 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FireServiceConfigEntry) -> bool: """Set up FireServiceRota from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - client = FireServiceRotaClient(hass, entry) await client.setup() @@ -32,19 +32,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: FireServiceConfigEntry +) -> bool: """Unload FireServiceRota config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index b8a542cf37c..be7add191c0 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -10,24 +10,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota binary sensor based on a config entry.""" - client: FireServiceRotaClient = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][ - DATA_CLIENT - ] - - coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ - entry.entry_id - ][DATA_COORDINATOR] + coordinator = entry.runtime_data + client = coordinator.client async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index c452421d57b..6815bf39104 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -28,12 +28,19 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +type FireServiceConfigEntry = ConfigEntry[FireServiceUpdateCoordinator] + class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): """Data update coordinator for FireServiceRota.""" + config_entry: FireServiceConfigEntry + def __init__( - self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry + self, + hass: HomeAssistant, + client: FireServiceRotaClient, + entry: FireServiceConfigEntry, ) -> None: """Initialize the FireServiceRota DataUpdateCoordinator.""" super().__init__( diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 682c7bcc0fd..5ed65609dc8 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -4,27 +4,24 @@ import logging from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient +from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceConfigEntry, FireServiceRotaClient _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota sensor based on a config entry.""" - client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] - - async_add_entities([IncidentsSensor(client)]) + async_add_entities([IncidentsSensor(entry.runtime_data.client)]) # pylint: disable-next=hass-invalid-inheritance # needs fixing diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index 602a02a8e4a..d9fe382e4b1 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -9,21 +9,24 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota switch based on a config entry.""" - client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] - - coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = entry.runtime_data + client = coordinator.client async_add_entities([ResponseSwitch(coordinator, client, entry)]) From bc11444fb298ea577b81606b96f298b1a98b6ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 12 Feb 2025 12:14:31 +0000 Subject: [PATCH 025/155] Add missing loggers to Cloud (#138374) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4528d9aa225..97210b4197c 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -423,7 +423,7 @@ async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler: handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) integration = await async_get_integration(hass, DOMAIN) - loggers: set[str] = {"snitun", integration.pkg_path, *(integration.loggers or [])} + loggers: set[str] = {integration.pkg_path, *(integration.loggers or [])} for logger_name in loggers: logging.getLogger(logger_name).addHandler(handler) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8e8ff4335db..73225b5ea56 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -12,7 +12,7 @@ "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", - "loggers": ["hass_nabucasa"], + "loggers": ["acme", "hass_nabucasa", "snitun"], "requirements": ["hass-nabucasa==0.89.0"], "single_config_entry": true } From 6084bee2d5403f42dd3058390ed0b0acfa4a78ac Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 14:14:52 +0100 Subject: [PATCH 026/155] Bump deebot-client to 12.1.0 (#138382) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33a251c22dc..79e0c34e4b9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf33b7966c7..8b757aa4aa2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -750,7 +750,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c5482fde6e..0f1929fc585 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 327bb34be1ccc98916bc3bef82a892e8a0013d14 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:15:32 +0100 Subject: [PATCH 027/155] Bump stookwijzer to 1.5.2 (#138384) Bump stookwijzer==1.5.2 --- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 3fe16fb3d33..0c97d1b20ed 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.1"] + "requirements": ["stookwijzer==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b757aa4aa2..b7aec98bc96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.5.2 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f1929fc585..e972f2d32b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2257,7 +2257,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.5.2 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 4807682fc55935072d24a0b6e66ffd2d50e48ee1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:18:10 +0100 Subject: [PATCH 028/155] Remove unused arguments in forked_daapd initialisation (#138289) --- .../components/forked_daapd/media_player.py | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 6bc69a64eaa..8cbf33460aa 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -85,9 +85,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - password = config_entry.data[CONF_PASSWORD] + host: str = config_entry.data[CONF_HOST] + port: int = config_entry.data[CONF_PORT] + password: str = config_entry.data[CONF_PASSWORD] forked_daapd_api = ForkedDaapdAPI( async_get_clientsession(hass), host, port, password ) @@ -95,8 +95,6 @@ async def async_setup_entry( clientsession=async_get_clientsession(hass), api=forked_daapd_api, ip_address=host, - api_port=port, - api_password=password, config_entry=config_entry, ) @@ -240,9 +238,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): _attr_should_poll = False - def __init__( - self, clientsession, api, ip_address, api_port, api_password, config_entry - ): + def __init__(self, clientsession, api, ip_address, config_entry): """Initialize the ForkedDaapd Master Device.""" # Leave the api public so the browse media helpers can use it self.api = api @@ -269,7 +265,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._on_remove = None self._available = False self._clientsession = clientsession - self._config_entry = config_entry + self._entry_id = config_entry.entry_id self.update_options(config_entry.options) self._paused_event = asyncio.Event() self._pause_requested = False @@ -282,42 +278,42 @@ class ForkedDaapdMaster(MediaPlayerEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_PLAYER.format(self._config_entry.entry_id), + SIGNAL_UPDATE_PLAYER.format(self._entry_id), self._update_player, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_QUEUE.format(self._config_entry.entry_id), + SIGNAL_UPDATE_QUEUE.format(self._entry_id), self._update_queue, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_OUTPUTS.format(self._config_entry.entry_id), + SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), self._update_outputs, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_MASTER.format(self._config_entry.entry_id), + SIGNAL_UPDATE_MASTER.format(self._entry_id), self._update_callback, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._config_entry.entry_id), + SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._entry_id), self.update_options, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_DATABASE.format(self._config_entry.entry_id), + SIGNAL_UPDATE_DATABASE.format(self._entry_id), self._update_database, ) ) @@ -411,9 +407,9 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._track_info = defaultdict(str) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" - return self._config_entry.entry_id + return self._entry_id @property def available(self) -> bool: From 910711ecba01f92bf0c3c4699fc89bf8338eff64 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 12 Feb 2025 13:54:21 +0000 Subject: [PATCH 029/155] Bump ohmepy to 1.3.0 (#138380) * Bump ohmepy to 1.3.0 * CI fix for enum change --- homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ohme/snapshots/test_sensor.ambr | 2 ++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 100967f819f..c1ca2bac62f 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.9"] + "requirements": ["ohme==1.3.0"] } diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index eb5bbffda52..b337c013727 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -73,7 +73,8 @@ "plugged_in": "Plugged in", "charging": "Charging", "paused": "[%key:common::state::paused%]", - "pending_approval": "Pending approval" + "pending_approval": "Pending approval", + "finished": "Finished charging" } }, "ct_current": { diff --git a/requirements_all.txt b/requirements_all.txt index b7aec98bc96..f258715dd28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,7 +1547,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.9 +ohme==1.3.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e972f2d32b1..447d21630f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1295,7 +1295,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.9 +ohme==1.3.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index b5c3c3b96d5..fc28b3b011c 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -222,6 +222,7 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'config_entry_id': , @@ -263,6 +264,7 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'context': , From 281c2bfb7bd1095a794f62fd81042786420b7718 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 15:29:42 +0100 Subject: [PATCH 030/155] Bump hass-nabucasa from 0.89.0 to 0.90.0 (#138387) * Bump hass-nabucasa from 0.89.0 to 0.90.0 * Use new shiny enum --- homeassistant/components/cloud/backup.py | 14 ++++++++------ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 9531604ccc7..83dc44c0ef7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,12 +8,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any, Literal +from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.files import StorageType from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -24,7 +25,6 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -106,7 +106,7 @@ class CloudBackupAgent(BackupAgent): try: content = await self._cloud.files.download( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except CloudError as err: @@ -138,7 +138,7 @@ class CloudBackupAgent(BackupAgent): while tries <= _RETRY_LIMIT: try: await self._cloud.files.upload( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -185,7 +185,7 @@ class CloudBackupAgent(BackupAgent): try: await async_files_delete_file( self._cloud, - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except (ClientError, CloudError) as err: @@ -194,7 +194,9 @@ class CloudBackupAgent(BackupAgent): async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: - backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + backups = await async_files_list( + self._cloud, storage_type=StorageType.BACKUP + ) _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 73225b5ea56..7598dde6cf3 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.89.0"], + "requirements": ["hass-nabucasa==0.90.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 43752fb558b..5b7ad8b5118 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 11bda6bf1fc..64163342723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.89.0", + "hass-nabucasa==0.90.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index fcfdded632b..6e428a96767 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f258715dd28..7b6ef5b23f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 447d21630f4..436186a9edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.conversation hassil==2.2.3 From 8bf870f296258647c55af42e236012cec726c4eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Feb 2025 08:57:26 -0600 Subject: [PATCH 031/155] Bump zeroconf to 0.144.1 (#138353) * Bump zeroconf to 0.143.1 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.143.0...0.143.1 fixes #138324 fixes https://github.com/home-assistant/core/issues/137731 fixes https://github.com/home-assistant/core/issues/138298 * one more --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index f4a78cd99e9..ddc74fba8bf 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.143.0"] + "requirements": ["zeroconf==0.144.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5b7ad8b5118..b35d5589182 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.143.0 +zeroconf==0.144.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 64163342723..c0d83b05f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.143.0" + "zeroconf==0.144.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 6e428a96767..4afa122ba7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.143.0 +zeroconf==0.144.1 diff --git a/requirements_all.txt b/requirements_all.txt index 7b6ef5b23f8..5f59bbdbd54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 436186a9edd..eb62baad569 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 620141cfb16762ca64e9b6d6bcb447763c9946f1 Mon Sep 17 00:00:00 2001 From: "Andre W." <10945277+alfwro13@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:24:39 +0000 Subject: [PATCH 032/155] Fix version extraction for APsystems (#138023) Co-authored-by: Marlon --- homeassistant/components/apsystems/entity.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 9ba7d046b60..2ce8becbf80 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -19,10 +19,20 @@ class ApSystemsEntity(Entity): data: ApSystemsData, ) -> None: """Initialize the APsystems entity.""" + + # Handle device version safely + sw_version = None + if data.coordinator.device_version: + version_parts = data.coordinator.device_version.split(" ") + if len(version_parts) > 1: + sw_version = version_parts[1] + else: + sw_version = version_parts[0] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.device_id)}, manufacturer="APsystems", model="EZ1-M", serial_number=data.device_id, - sw_version=data.coordinator.device_version.split(" ")[1], + sw_version=sw_version, ) From ff5ddce7b0c07ea731f5fd92d52930bbc71f7b4e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Feb 2025 18:37:30 +0100 Subject: [PATCH 033/155] Add sensor platform to OneDrive for drive usage (#138232) --- homeassistant/components/onedrive/__init__.py | 28 +-- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/coordinator.py | 70 ++++++ homeassistant/components/onedrive/icons.json | 24 ++ .../components/onedrive/quality_scale.yaml | 80 ++---- homeassistant/components/onedrive/sensor.py | 122 ++++++++++ .../components/onedrive/strings.json | 25 ++ tests/components/onedrive/conftest.py | 3 +- tests/components/onedrive/const.py | 18 ++ .../onedrive/snapshots/test_init.ambr | 34 +++ .../onedrive/snapshots/test_sensor.ambr | 227 ++++++++++++++++++ tests/components/onedrive/test_init.py | 32 ++- tests/components/onedrive/test_sensor.py | 64 +++++ 13 files changed, 647 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/onedrive/coordinator.py create mode 100644 homeassistant/components/onedrive/icons.json create mode 100644 homeassistant/components/onedrive/sensor.py create mode 100644 tests/components/onedrive/snapshots/test_init.ambr create mode 100644 tests/components/onedrive/snapshots/test_sensor.ambr create mode 100644 tests/components/onedrive/test_sensor.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 8355cddb0b5..c82757dca31 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass from html import unescape from json import dumps, loads import logging @@ -17,8 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from onedrive_personal_sdk.models.items import ItemUpdate -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -29,18 +26,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.instance_id import async_get as async_get_instance_id from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .coordinator import ( + OneDriveConfigEntry, + OneDriveRuntimeData, + OneDriveUpdateCoordinator, +) +PLATFORMS = [Platform.SENSOR] -@dataclass -class OneDriveRuntimeData: - """Runtime data for the OneDrive integration.""" - - client: OneDriveClient - token_function: Callable[[], Awaitable[str]] - backup_folder_id: str - - -type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] _LOGGER = logging.getLogger(__name__) @@ -85,10 +78,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_placeholders={"folder": backup_folder_name}, ) from err + coordinator = OneDriveUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = OneDriveRuntimeData( client=client, token_function=get_access_token, backup_folder_id=backup_folder.id, + coordinator=coordinator, ) try: @@ -100,6 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> ) from err _async_notify_backup_listeners_soon(hass) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -107,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" _async_notify_backup_listeners_soon(hass) - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def _async_notify_backup_listeners(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 343c332f384..0e89f1b590f 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -30,8 +30,8 @@ from homeassistant.components.backup import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import OneDriveConfigEntry from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .coordinator import OneDriveConfigEntry _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py new file mode 100644 index 00000000000..cc759437c07 --- /dev/null +++ b/homeassistant/components/onedrive/coordinator.py @@ -0,0 +1,70 @@ +"""Coordinator for OneDrive.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.models.items import Drive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + client: OneDriveClient + token_function: Callable[[], Awaitable[str]] + backup_folder_id: str + coordinator: OneDriveUpdateCoordinator + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + + +class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): + """Class to handle fetching data from the Graph API centrally.""" + + config_entry: OneDriveConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._client = client + + async def _async_update_data(self) -> Drive: + """Fetch data from API endpoint.""" + + try: + drive = await self._client.get_drive() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OneDriveException as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + return drive diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json new file mode 100644 index 00000000000..b693f69934e --- /dev/null +++ b/homeassistant/components/onedrive/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "total_size": { + "default": "mdi:database" + }, + "used_size": { + "default": "mdi:database" + }, + "remaining_size": { + "default": "mdi:database" + }, + "drive_state": { + "default": "mdi:harddisk", + "state": { + "normal": "mdi:harddisk", + "nearing": "mdi:alert-circle-outline", + "critical": "mdi:alert", + "exceeded": "mdi:alert-octagon" + } + } + } + } +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index f0d58d89c9a..ff95364859a 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -3,10 +3,7 @@ rules: action-setup: status: exempt comment: Integration does not register custom actions. - appropriate-polling: - status: exempt - comment: | - This integration does not poll. + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done @@ -23,14 +20,8 @@ rules: status: exempt comment: | Entities of this integration does not explicitly subscribe to events. - entity-unique-id: - status: exempt - comment: | - This integration does not have entities. - has-entity-name: - status: exempt - comment: | - This integration does not have entities. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -44,27 +35,15 @@ rules: comment: | No Options flow. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: | - This integration does not have entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration does not have entities. - parallel-updates: - status: exempt - comment: | - This integration does not have platforms. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done test-coverage: todo # Gold - devices: - status: exempt - comment: | - This integration connects to a single service. + devices: done diagnostics: status: exempt comment: | @@ -77,53 +56,26 @@ rules: status: exempt comment: | This integration is a cloud service and does not support discovery. - docs-data-update: - status: exempt - comment: | - This integration does not poll or push. - docs-examples: - status: exempt - comment: | - This integration only serves backup. + docs-data-update: done + docs-examples: done docs-known-limitations: done docs-supported-devices: status: exempt comment: | This integration is a cloud service. - docs-supported-functions: - status: exempt - comment: | - This integration does not have entities. - docs-troubleshooting: - status: exempt - comment: | - No issues known to troubleshoot. + docs-supported-functions: done + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt comment: | This integration connects to a single service. - entity-category: - status: exempt - comment: | - This integration does not have entities. - entity-device-class: - status: exempt - comment: | - This integration does not have entities. - entity-disabled-by-default: - status: exempt - comment: | - This integration does not have entities. - entity-translations: - status: exempt - comment: | - This integration does not have entities. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: | - This integration does not have entities. + icon-translations: done reconfiguration-flow: status: exempt comment: | diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py new file mode 100644 index 00000000000..35c59d0c644 --- /dev/null +++ b/homeassistant/components/onedrive/sensor.py @@ -0,0 +1,122 @@ +"""Sensors for OneDrive.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.models.items import DriveQuota + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry, OneDriveUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class OneDriveSensorEntityDescription(SensorEntityDescription): + """Describes OneDrive sensor entity.""" + + value_fn: Callable[[DriveQuota], StateType] + + +DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( + OneDriveSensorEntityDescription( + key="total_size", + value_fn=lambda quota: quota.total, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OneDriveSensorEntityDescription( + key="used_size", + value_fn=lambda quota: quota.used, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="remaining_size", + value_fn=lambda quota: quota.remaining, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="drive_state", + value_fn=lambda quota: quota.state.value, + options=[state.value for state in DriveState], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OneDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OneDrive sensors based on a config entry.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + OneDriveDriveStateSensor(coordinator, description) + for description in DRIVE_STATE_ENTITIES + ) + + +class OneDriveDriveStateSensor( + CoordinatorEntity[OneDriveUpdateCoordinator], SensorEntity +): + """Define a OneDrive sensor.""" + + entity_description: OneDriveSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OneDriveUpdateCoordinator, + description: OneDriveSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = f"{coordinator.data.id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data.name, + identifiers={(DOMAIN, coordinator.data.id)}, + manufacturer="Microsoft", + model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", + configuration_url=f"https://onedrive.live.com/?id=root&cid={coordinator.data.id}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + assert self.coordinator.data.quota + return self.entity_description.value_fn(self.coordinator.data.quota) + + @property + def available(self) -> bool: + """Availability of the sensor.""" + return super().available and self.coordinator.data.quota is not None diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index ebc46d3eb12..c3087d435b8 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -38,6 +38,31 @@ }, "failed_to_migrate_files": { "message": "Failed to migrate metadata to separate files" + }, + "update_failed": { + "message": "Failed to update drive state" + } + }, + "entity": { + "sensor": { + "total_size": { + "name": "Total available storage" + }, + "used_size": { + "name": "Used storage" + }, + "remaining_size": { + "name": "Remaining storage" + }, + "drive_state": { + "name": "Drive state", + "state": { + "normal": "Normal", + "nearing": "Nearing limit", + "critical": "Critical", + "exceeded": "Exceeded" + } + } } } } diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 8a0da9f584e..ed419c820a9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -22,6 +22,7 @@ from .const import ( MOCK_APPROOT, MOCK_BACKUP_FILE, MOCK_BACKUP_FOLDER, + MOCK_DRIVE, MOCK_METADATA_FILE, ) @@ -104,7 +105,7 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi return dumps(BACKUP_METADATA).encode() client.download_drive_item.return_value = MockStreamReader() - + client.get_drive.return_value = MOCK_DRIVE return client diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 3ba54dc40d7..44f50aa625d 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -3,8 +3,11 @@ from html import escape from json import dumps +from onedrive_personal_sdk.const import DriveState, DriveType from onedrive_personal_sdk.models.items import ( AppRoot, + Drive, + DriveQuota, File, Folder, Hashes, @@ -98,3 +101,18 @@ MOCK_METADATA_FILE = File( ), created_by=IDENTITY_SET, ) + + +MOCK_DRIVE = Drive( + id="mock_drive_id", + name="My Drive", + drive_type=DriveType.PERSONAL, + owner=IDENTITY_SET, + quota=DriveQuota( + deleted=5, + remaining=750000000, + state=DriveState.NEARING, + total=5000000000, + used=4250000000, + ), +) diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr new file mode 100644 index 00000000000..9b2ed7e4d94 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onedrive', + 'mock_drive_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Microsoft', + 'model': 'OneDrive Personal', + 'model_id': None, + 'name': 'My Drive', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..43c6921b0e5 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -0,0 +1,227 @@ +# serializer version: 1 +# name: test_sensors[sensor.my_drive_drive_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_drive_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Drive state', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state', + 'unique_id': 'mock_drive_id_drive_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.my_drive_drive_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My Drive Drive state', + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_drive_drive_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'nearing', + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_size', + 'unique_id': 'mock_drive_id_remaining_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Remaining storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.75', + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total available storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_size', + 'unique_id': 'mock_drive_id_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Total available storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_used_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'used_size', + 'unique_id': 'mock_drive_id_used_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Used storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_used_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.25', + }) +# --- diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 7ceab98ff21..65c3e62629c 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -6,12 +6,15 @@ from unittest.mock import MagicMock from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest +from syrupy import SnapshotAssertion +from homeassistant.components.onedrive.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE from tests.common import MockConfigEntry @@ -101,3 +104,30 @@ async def test_migrate_metadata_files_errors( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_error_during_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test auth error during update.""" + mock_onedrive_client.get_drive.side_effect = AuthenticationError(403, "Auth failed") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device.""" + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) + assert device + assert device == snapshot diff --git a/tests/components/onedrive/test_sensor.py b/tests/components/onedrive/test_sensor.py new file mode 100644 index 00000000000..ea9d93a9a7b --- /dev/null +++ b/tests/components/onedrive/test_sensor.py @@ -0,0 +1,64 @@ +"""Tests for OneDrive sensors.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from onedrive_personal_sdk.const import DriveType +from onedrive_personal_sdk.exceptions import HttpRequestException +from onedrive_personal_sdk.models.items import Drive +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the OneDrive sensors.""" + + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("attr", "side_effect"), + [ + ("side_effect", HttpRequestException(503, "Service Unavailable")), + ("return_value", Drive(id="id", name="name", drive_type=DriveType.PERSONAL)), + ], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + freezer: FrozenDateTimeFactory, + attr: str, + side_effect: Any, +) -> None: + """Ensure sensors are going unavailable on update failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == "0.75" + + setattr(mock_onedrive_client.get_drive, attr, side_effect) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == STATE_UNAVAILABLE From d9108cc0035e05ae0560114382c753862ef317da Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:46:11 +0000 Subject: [PATCH 034/155] Fix tplink iot strip sensor refresh (#138375) --- .../components/tplink/coordinator.py | 20 ++++++------------- homeassistant/components/tplink/entity.py | 16 +++++++-------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index fcd1335a77a..1a7b40457f0 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -9,6 +9,7 @@ import logging from kasa import AuthenticationError, Credentials, Device, KasaException from kasa.iot import IotStrip +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, - parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() - if not self._update_children: - # If the children are not being updated, it means this is an - # IotStrip, and we need to tell the children to write state - # since the power state is provided by the parent. - for child_coordinator in self._child_coordinators.values(): - child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): def get_child_coordinator( self, child: Device, + platform_domain: str, ) -> TPLinkDataUpdateCoordinator: """Get separate child coordinator for a device or self if not needed.""" # The iot HS300 allows a limited number of concurrent requests and fetching the # emeter information requires separate ones so create child coordinators here. - if isinstance(self.device, IotStrip): + # This does not happen for switches as the state is available on the + # parent device info. + if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN: if not (child_coordinator := self._child_coordinators.get(child.device_id)): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, - child, - timedelta(seconds=60), - self.config_entry, - parent_coordinator=self, + self.hass, child, timedelta(seconds=60), self.config_entry ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 7a0d811b30d..7c1e9e72b85 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - coordinator = self.coordinator - if coordinator.parent_coordinator: - # If there is a parent coordinator we need to refresh - # the parent as its what provides the power state data - # for the child entities. - coordinator = coordinator.parent_coordinator - await coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() return _async_wrap @@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities = cls._entities_for_device( hass, @@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): device.host, ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities: list[_E] = cls._entities_for_device( hass, From 400dbc8d1b89b3d0e0330e22d4336a7f983b9ac5 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 10:56:42 -0700 Subject: [PATCH 035/155] Add missing thermostat state EMERGENCY_HEAT to econet (#137623) * Add missing thermostat state EMERGENCY_HEAT to econet * econet: fix overloaded reverse dictionary * Update homeassistant/components/econet/climate.py --------- Co-authored-by: Robert Resch --- homeassistant/components/econet/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cb2374bd69b..e7ccec33310 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = { ThermostatOperationMode.OFF: HVACMode.OFF, ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL, ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY, + ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT, +} +HA_STATE_TO_ECONET = { + value: key + for key, value in ECONET_STATE_TO_HA.items() + if key != ThermostatOperationMode.EMERGENCY_HEAT } -HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} ECONET_FAN_STATE_TO_HA = { ThermostatFanMode.AUTO: FAN_AUTO, From 03b3097c348b7950c443732b477fb65a1d7b6350 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 19:11:20 +0100 Subject: [PATCH 036/155] Update cloud backup agent to use calculate_b64md5 from lib (#138391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update cloud backup agent to use calculate_b64md5 from lib * Catch error, add test * Address review comments * Update tests/components/cloud/test_backup.py Co-authored-by: Abílio Costa --------- Co-authored-by: Abílio Costa --- homeassistant/components/cloud/backup.py | 19 ++----- tests/components/cloud/test_backup.py | 72 ++++++++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 83dc44c0ef7..61edeccdd9c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping -import hashlib import logging import random from typing import Any @@ -14,7 +12,7 @@ from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list -from hass_nabucasa.files import StorageType +from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -30,14 +28,6 @@ _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 -async def _b64md5(stream: AsyncIterator[bytes]) -> str: - """Calculate the MD5 hash of a file.""" - file_hash = hashlib.md5() - async for chunk in stream: - file_hash.update(chunk) - return base64.b64encode(file_hash.digest()).decode() - - async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -129,10 +119,13 @@ class CloudBackupAgent(BackupAgent): if not backup.protected: raise BackupAgentError("Cloud backups must be protected") - base64md5hash = await _b64md5(await open_stream()) + size = backup.size + try: + base64md5hash = await calculate_b64md5(open_stream, size) + except FilesError as err: + raise BackupAgentError(err) from err filename = self._get_backup_filename() metadata = backup.as_dict() - size = backup.size tries = 1 while tries <= _RETRY_LIMIT: diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 6e59b7d983e..c6bb0bdad54 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -285,6 +285,7 @@ async def test_agents_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -297,7 +298,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) with ( patch( @@ -309,11 +310,11 @@ async def test_agents_upload( ), patch("pathlib.Path.open") as mocked_open, ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 1 @@ -336,6 +337,7 @@ async def test_agents_upload_fail( ) -> None: """Test agent upload backup fails.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -348,7 +350,7 @@ async def test_agents_upload_fail( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) cloud.files.upload.side_effect = side_effect @@ -366,11 +368,11 @@ async def test_agents_upload_fail( patch("homeassistant.components.cloud.backup.random.randint", return_value=60), patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -409,6 +411,7 @@ async def test_agents_upload_fail_non_retryable( ) -> None: """Test agent upload backup fails with non-retryable error.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -435,12 +438,13 @@ async def test_agents_upload_fail_non_retryable( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -461,6 +465,7 @@ async def test_agents_upload_not_protected( ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -473,7 +478,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0, + size=len(backup_data), ) with ( patch("pathlib.Path.open"), @@ -484,7 +489,7 @@ async def test_agents_upload_not_protected( ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -496,6 +501,53 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_wrong_size( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + cloud: Mock, +) -> None: + """Test agent upload backup with the wrong size.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data) - 1, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + + assert len(cloud.files.upload.mock_calls) == 0 + + assert resp.status == 201 + assert "Upload failed for cloud.cloud" in caplog.text + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, From 641b487196103372432e7dc263b080b7ade20f2b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Feb 2025 20:44:39 +0100 Subject: [PATCH 037/155] Improve test coverage for onedrive (#138410) * Improve test coverage for onedrive * set done in quality scale --- homeassistant/components/onedrive/backup.py | 3 +- .../components/onedrive/quality_scale.yaml | 2 +- tests/components/onedrive/test_backup.py | 39 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 0e89f1b590f..674708b0cb3 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -25,6 +25,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentError, + BackupNotFound, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -137,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent): """Download a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - raise BackupAgentError("Backup not found") + raise BackupNotFound("Backup not found") stream = await self._client.download_drive_item( backups[backup_id].backup_file_id, timeout=TIMEOUT diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index ff95364859a..84b980c5e01 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -40,7 +40,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index dd4f4d253d0..41ecbdb240f 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -14,13 +14,16 @@ from onedrive_personal_sdk.exceptions import ( import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import setup_integration -from .const import BACKUP_METADATA +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_METADATA_FILE from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator @@ -241,6 +244,26 @@ async def test_agents_download( assert await resp.content.read() == b"backup data" +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on an not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + mock_onedrive_client.list_drive_items.side_effect = [ + [MOCK_BACKUP_FILE, MOCK_METADATA_FILE], + [], + ] + + with patch("homeassistant.components.onedrive.backup.CACHE_TTL", -1): + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 404 + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -349,3 +372,15 @@ async def test_reauth_on_403( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None From c0068e0891c32e38945b238af43e0a66125b5d20 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 20:42:07 +0000 Subject: [PATCH 038/155] Bump python-kasa to 0.10.2 (#138381) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index ff65211c9b3..cdd6ab57c6a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.10.1"] + "requirements": ["python-kasa[speedups]==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f59bbdbd54..c9d118adb8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2415,7 +2415,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb62baad569..335326545a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1954,7 +1954,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 From 81cac25bd01c26b8076c938a40a3677d9baf58b0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:48:09 -0500 Subject: [PATCH 039/155] OTBR firmware API for Home Assistant Hardware (#138330) * Implement `async_register_firmware_info_provider` for OTBR * Keep track of the current device for OTBR Keep track of the current device, part 2 * Fix unit tests * Revert keeping track of the current device * Fix existing unit tests * Increase test coverage * Remove unused code from tests * Reload OTBR when the addon reloads * Only reload if the current entry is running * Runtime test * Add a unit test for the reloading * Clarify the purpose of `ConfigEntryState.SETUP_IN_PROGRESS` * Simplify typing --- .../components/homeassistant_hardware/util.py | 60 +++-- homeassistant/components/otbr/__init__.py | 16 +- homeassistant/components/otbr/config_flow.py | 22 +- .../components/otbr/homeassistant_hardware.py | 76 ++++++ homeassistant/components/otbr/types.py | 7 + homeassistant/components/otbr/util.py | 5 + .../homeassistant_hardware/test_util.py | 35 ++- tests/components/otbr/__init__.py | 6 + tests/components/otbr/conftest.py | 13 + tests/components/otbr/test_config_flow.py | 82 +++++- .../otbr/test_homeassistant_hardware.py | 254 ++++++++++++++++++ tests/components/otbr/test_init.py | 3 + 12 files changed, 548 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/otbr/homeassistant_hardware.py create mode 100644 homeassistant/components/otbr/types.py create mode 100644 tests/components/otbr/test_homeassistant_hardware.py diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 53cbcbae5d4..bd1ff642d10 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -12,7 +12,7 @@ import logging from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.flasher import Flasher -from homeassistant.components.hassio import AddonError, AddonState +from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.hassio import is_hassio @@ -143,6 +143,31 @@ class FirmwareInfo: return all(states) +async def get_otbr_addon_firmware_info( + hass: HomeAssistant, otbr_addon_manager: AddonManager +) -> FirmwareInfo | None: + """Get firmware info from the OTBR add-on.""" + try: + otbr_addon_info = await otbr_addon_manager.async_get_addon_info() + except AddonError: + return None + + if otbr_addon_info.state == AddonState.NOT_INSTALLED: + return None + + if (otbr_path := otbr_addon_info.options.get("device")) is None: + return None + + # Only create a new entry if there are no existing OTBR ones + return FirmwareInfo( + device=otbr_path, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], + ) + + async def guess_hardware_owners( hass: HomeAssistant, device_path: str ) -> list[FirmwareInfo]: @@ -155,28 +180,19 @@ async def guess_hardware_owners( # It may be possible for the OTBR addon to be present without the integration if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) + otbr_addon_fw_info = await get_otbr_addon_firmware_info( + hass, otbr_addon_manager + ) + otbr_path = ( + otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None + ) - try: - otbr_addon_info = await otbr_addon_manager.async_get_addon_info() - except AddonError: - pass - else: - if otbr_addon_info.state != AddonState.NOT_INSTALLED: - otbr_path = otbr_addon_info.options.get("device") - - # Only create a new entry if there are no existing OTBR ones - if otbr_path is not None and not any( - info.source == "otbr" for info in device_guesses[otbr_path] - ): - device_guesses[otbr_path].append( - FirmwareInfo( - device=otbr_path, - firmware_type=ApplicationType.SPINEL, - firmware_version=None, - source="otbr", - owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], - ) - ) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + assert otbr_addon_fw_info is not None + device_guesses[otbr_path].append(otbr_addon_fw_info) if is_hassio(hass): multipan_addon_manager = await get_multiprotocol_addon_manager(hass) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 4b95be1d40d..0756f32ab18 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -7,16 +7,20 @@ import logging import aiohttp import python_otbr_api +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.components.thread import async_add_dataset -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from . import websocket_api +from . import homeassistant_hardware, websocket_api from .const import DOMAIN +from .types import OTBRConfigEntry from .util import ( GetBorderAgentIdNotSupported, OTBRData, @@ -28,12 +32,13 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type OTBRConfigEntry = ConfigEntry[OTBRData] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" websocket_api.async_setup(hass) + + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -77,6 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool entry.async_on_unload(entry.add_update_listener(async_reload_entry)) entry.runtime_data = otbrdata + if fw_info := await homeassistant_hardware.async_get_firmware_info(hass, entry): + await async_notify_firmware_info(hass, DOMAIN, fw_info) + return True diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index aff79ca4651..514f6c7617c 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -16,7 +16,12 @@ import yarl from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_HASSIO, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -201,12 +206,23 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): # we have to assume it's the first version # This check can be removed in HA Core 2025.9 unique_id = discovery_info.uuid + + if unique_id != discovery_info.uuid: + continue + if ( - unique_id != discovery_info.uuid - or current_url.host != config["host"] + current_url.host != config["host"] or current_url.port == config["port"] ): + # Reload the entry since OTBR has restarted + if current_entry.state == ConfigEntryState.LOADED: + assert current_entry.unique_id is not None + await self.hass.config_entries.async_reload( + current_entry.entry_id + ) + continue + # Update URL with the new port self.hass.config_entries.async_update_entry( current_entry, diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py new file mode 100644 index 00000000000..94193be1359 --- /dev/null +++ b/homeassistant/components/otbr/homeassistant_hardware.py @@ -0,0 +1,76 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +import logging + +from yarl import URL + +from homeassistant.components.hassio import AddonManager +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, + get_otbr_addon_firmware_info, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.hassio import is_hassio + +from .const import DOMAIN +from .types import OTBRConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_firmware_info( + hass: HomeAssistant, config_entry: OTBRConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the OpenThread Border Router.""" + owners: list[OwningIntegration | OwningAddon] = [ + OwningIntegration(config_entry_id=config_entry.entry_id) + ] + + device = None + + if is_hassio(hass) and (host := URL(config_entry.data["url"]).host) is not None: + otbr_addon_manager = AddonManager( + hass=hass, + logger=_LOGGER, + addon_name="OpenThread Border Router", + addon_slug=host.replace("-", "_"), + ) + + if ( + addon_fw_info := await get_otbr_addon_firmware_info( + hass, otbr_addon_manager + ) + ) is not None: + device = addon_fw_info.device + owners.extend(addon_fw_info.owners) + + firmware_version = None + + if config_entry.state in ( + # This function is called during OTBR config entry setup so we need to account + # for both config entry states + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + try: + firmware_version = await config_entry.runtime_data.get_coprocessor_version() + except HomeAssistantError: + firmware_version = None + + if device is None: + return None + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.SPINEL, + firmware_version=firmware_version, + source=DOMAIN, + owners=owners, + ) diff --git a/homeassistant/components/otbr/types.py b/homeassistant/components/otbr/types.py new file mode 100644 index 00000000000..eff6aa980d6 --- /dev/null +++ b/homeassistant/components/otbr/types.py @@ -0,0 +1,7 @@ +"""The Open Thread Border Router integration types.""" + +from homeassistant.config_entries import ConfigEntry + +from .util import OTBRData + +type OTBRConfigEntry = ConfigEntry[OTBRData] diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 351e23c7736..30e456e11a8 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -163,6 +163,11 @@ class OTBRData: """Get extended address (EUI-64).""" return await self.api.get_extended_address() + @_handle_otbr_error + async def get_coprocessor_version(self) -> str: + """Get coprocessor firmware version.""" + return await self.api.get_coprocessor_version() + async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 047de3e452c..52739f16886 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.hassio import ( + AddonError, + AddonInfo, + AddonManager, + AddonState, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_provider, ) @@ -11,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, OwningAddon, OwningIntegration, + get_otbr_addon_firmware_info, guess_firmware_info, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -247,3 +253,30 @@ async def test_firmware_info(hass: HomeAssistant) -> None: ) assert (await firmware_info2.is_running(hass)) is False + + +async def test_get_otbr_addon_firmware_info_failure(hass: HomeAssistant) -> None: + """Test getting OTBR addon firmware info failure due to bad API call.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.side_effect = AddonError() + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None + + +async def test_get_otbr_addon_firmware_info_failure_bad_options( + hass: HomeAssistant, +) -> None: + """Test getting OTBR addon firmware info failure due to bad addon options.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, # `device` is missing + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 7d52318b477..5f778169e55 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -33,6 +33,8 @@ TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") TEST_BORDER_AGENT_ID_2 = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52D") +COPROCESSOR_VERSION = "OPENTHREAD/thread-reference-20200818-1740-g33cc75ed3; NRF52840; Jun 2 2022 14:25:49" + ROUTER_DISCOVERY_HASS = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", @@ -60,3 +62,7 @@ ROUTER_DISCOVERY_HASS = { }, "interface_index": None, } + +TEST_COPROCESSOR_VERSION = ( + "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57" +) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 5ab3e442183..9140fcf6847 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -15,6 +15,7 @@ from . import ( DATASET_CH16, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -71,12 +72,23 @@ def get_extended_address_fixture() -> Generator[AsyncMock]: yield get_extended_address +@pytest.fixture(name="get_coprocessor_version") +def get_coprocessor_version_fixture() -> Generator[AsyncMock]: + """Mock get_coprocessor_version.""" + with patch( + "python_otbr_api.OTBR.get_coprocessor_version", + return_value=TEST_COPROCESSOR_VERSION, + ) as get_coprocessor_version: + yield get_coprocessor_version + + @pytest.fixture(name="otbr_config_entry_multipan") async def otbr_config_entry_multipan_fixture( hass: HomeAssistant, get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> str: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( @@ -97,6 +109,7 @@ async def otbr_config_entry_thread_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index d14fbc5cbd1..8384b905b9c 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -10,9 +10,18 @@ import pytest import python_otbr_api from homeassistant.components import otbr +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.setup import async_setup_component from . import DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID, TEST_BORDER_AGENT_ID_2 @@ -32,6 +41,19 @@ HASSIO_DATA_2 = HassioServiceInfo( uuid="23456", ) +HASSIO_DATA_OTBR = HassioServiceInfo( + config={ + "host": "core-openthread-border-router", + "port": 8081, + "device": "/dev/ttyUSB1", + "firmware": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57\r", + "addon": "OpenThread Border Router", + }, + name="OpenThread Border Router", + slug="core_openthread_border_router", + uuid="c58ba80fc88548008776bf8da903ef21", +) + @pytest.fixture(name="otbr_addon_info") def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock: @@ -97,6 +119,7 @@ async def test_user_flow_additional_entry( @pytest.mark.usefixtures( "get_active_dataset_tlvs", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_fail_get_address( hass: HomeAssistant, @@ -174,6 +197,7 @@ async def _finish_user_flow( "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_same_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -563,7 +587,11 @@ async def test_hassio_discovery_flow_2x_addons( assert config_entry.unique_id == HASSIO_DATA_2.uuid -@pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address") +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_extended_address", + "get_coprocessor_version", +) async def test_hassio_discovery_flow_2x_addons_same_ext_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: @@ -963,3 +991,55 @@ async def test_config_flow_additional_entry( ) assert result["type"] is expected_result + + +@pytest.mark.usefixtures( + "get_border_agent_id", "get_extended_address", "get_coprocessor_version" +) +async def test_hassio_discovery_reload( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info +) -> None: + """Test the hassio discovery flow.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + + aioclient_mock.get( + "http://core-openthread-border-router:8081/node/dataset/active", text="" + ) + + callback = Mock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB1", callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + # OTBR is set up and calls the firmware info notification callback + assert len(callback.mock_calls) == 1 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 + + # If we change discovery info and emit again, the integration will be reloaded + # and firmware information will be broadcast again + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + assert len(callback.mock_calls) == 2 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 diff --git a/tests/components/otbr/test_homeassistant_hardware.py b/tests/components/otbr/test_homeassistant_hardware.py new file mode 100644 index 00000000000..7f831656d06 --- /dev/null +++ b/tests/components/otbr/test_homeassistant_hardware.py @@ -0,0 +1,254 @@ +"""Test Home Assistant Hardware platform for OTBR.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, +) +from homeassistant.components.otbr.homeassistant_hardware import async_get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from . import TEST_COPROCESSOR_VERSION + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_PATH = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9ab1da1ea4b3ed11956f4eaca7669f5d-if00-port0" + + +async def test_get_firmware_info(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info`.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=TEST_COPROCESSOR_VERSION, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +async def test_get_firmware_info_ignored(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with ignored entry.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={}, + version=1, + ) + otbr.add_to_hass(hass) + + fw_info = await async_get_firmware_info(hass, otbr) + assert fw_info is None + + +async def test_get_firmware_info_no_coprocessor_version(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.side_effect = HomeAssistantError() + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +@pytest.mark.parametrize( + ("version", "expected_version"), + [ + ((TEST_COPROCESSOR_VERSION,), TEST_COPROCESSOR_VERSION), + (HomeAssistantError(), None), + ], +) +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + version: str | Exception, + expected_version: str | None, + get_active_dataset_tlvs: AsyncMock, + get_border_agent_id: AsyncMock, + get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the OTBR provides hardware and firmware information.""" + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = Mock() + async_register_firmware_info_callback(hass, DEVICE_PATH, callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + get_coprocessor_version.side_effect = version + await hass.config_entries.async_setup(otbr.entry_id) + + assert callback.mock_calls == [ + call( + FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=expected_version, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + ) + ] + + +async def test_get_firmware_info_remote_otbr(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://192.168.1.10:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=None, + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info is None diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index faf13786107..b14527165e6 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -26,6 +26,7 @@ from . import ( ROUTER_DISCOVERY_HASS, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -43,6 +44,7 @@ def enable_mocks_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Enable API mocks.""" @@ -298,6 +300,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: mock_api.get_extended_address = AsyncMock( return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS ) + mock_api.get_coprocessor_version = AsyncMock(return_value=TEST_COPROCESSOR_VERSION) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) From 1ac16f6dbf765659e37443afeb9b7af43a64b44b Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 02:37:46 -0500 Subject: [PATCH 040/155] Set suggested display precision in La Crosse View (#138355) * Set suggested display precision in La Crosse View * Switch to entity descriptions --- homeassistant/components/lacrosse_view/sensor.py | 9 +++++++++ tests/components/lacrosse_view/test_sensor.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index df66b7ba96a..ea5a82a3df8 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, ), "Humidity": LaCrosseSensorEntityDescription( key="Humidity", @@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, ), "HeatIndex": LaCrosseSensorEntityDescription( key="HeatIndex", @@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindSpeed": LaCrosseSensorEntityDescription( key="WindSpeed", @@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=2, ), "Rain": LaCrosseSensorEntityDescription( key="Rain", @@ -93,12 +97,14 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, + suggested_display_precision=2, ), "WindHeading": LaCrosseSensorEntityDescription( key="WindHeading", translation_key="wind_heading", value_fn=get_value, native_unit_of_measurement=DEGREE, + suggested_display_precision=2, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", @@ -117,6 +123,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, + suggested_display_precision=2, ), "FeelsLike": LaCrosseSensorEntityDescription( key="FeelsLike", @@ -125,6 +132,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -133,6 +141,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), } # map of API returned unit of measurement strings to their corresponding unit of measurement diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 74e9f001792..17ae56ed78d 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -117,7 +117,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), - (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6111111111111", "temperature"), ], ) async def test_field_types( From 737baaef2b93e3a696bbc6751f567426ed75d50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 13 Feb 2025 09:22:05 +0100 Subject: [PATCH 041/155] Improve test coverage for letpot (#138420) --- .../components/letpot/quality_scale.yaml | 2 +- tests/components/letpot/test_init.py | 37 +++++++++++++++- tests/components/letpot/test_switch.py | 44 ++++++++++++++++++- tests/components/letpot/test_time.py | 20 +++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0eda413a461..70f3bb52b82 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -44,7 +44,7 @@ rules: log-when-unavailable: todo parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index 178227a6506..e3f78d87dc1 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock -from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +from letpot.exceptions import ( + LetPotAuthenticationException, + LetPotConnectionException, + LetPotException, +) import pytest from homeassistant.config_entries import ConfigEntryState @@ -94,3 +98,34 @@ async def test_get_devices_exceptions( assert mock_config_entry.state is config_entry_state mock_client.get_devices.assert_called_once() mock_device_client.subscribe.assert_not_called() + + +async def test_device_subscribe_authentication_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors if it is not allowed to subscribe to device updates.""" + mock_device_client.subscribe.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_not_called() + + +async def test_device_refresh_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors with retry if getting a device state update fails.""" + mock_device_client.get_current_status.side_effect = LetPotException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_device_client.get_current_status.assert_called_once() diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index b166d551adb..0ba1f556bc9 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -6,7 +6,11 @@ from letpot.exceptions import LetPotConnectionException, LetPotException import pytest from syrupy import SnapshotAssertion -from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -32,6 +36,44 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize( + ("service", "parameter_value"), + [ + ( + SERVICE_TURN_ON, + True, + ), + ( + SERVICE_TURN_OFF, + False, + ), + ( + SERVICE_TOGGLE, + False, # Mock switch is on after setup, toggle will turn off + ), + ], +) +async def test_set_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + parameter_value: bool, +) -> None: + """Test switch entity turned on/turned off/toggled.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + ) + + mock_device_client.set_power.assert_awaited_once_with(parameter_value) + + @pytest.mark.parametrize( ("service", "exception", "user_error"), [ diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index 82e69979067..e65ea4532e1 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -33,6 +33,26 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test setting the time entity.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) + + mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + + @pytest.mark.parametrize( ("exception", "user_error"), [ From 6bc4f04a079142ccf763d30e6ba19c3170f5a7ee Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 03:24:28 -0500 Subject: [PATCH 042/155] Handle no_readings in La Crosse View (#138354) * Handle no_readings in La Crosse View * Fixes --- .../components/lacrosse_view/coordinator.py | 28 +++++++--- .../components/lacrosse_view/strings.json | 5 ++ tests/components/lacrosse_view/__init__.py | 22 ++++++++ tests/components/lacrosse_view/test_init.py | 17 ++++++ tests/components/lacrosse_view/test_sensor.py | 56 +++++++++++++++++++ 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 3d741e8f1a8..16d7e8b2bb8 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): try: # Fetch last hour of data for sensor in self.devices: - sensor.data = ( - await self.api.get_sensor_status( - sensor=sensor, - tz=self.hass.config.time_zone, + data = await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + _LOGGER.debug("Got data: %s", data) + + if data_error := data.get("error"): + if data_error == "no_readings": + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + _LOGGER.debug("Error: %s", data_error) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" ) - )["data"]["current"] - _LOGGER.debug("Got data: %s", sensor.data) + + sensor.data = data["data"]["current"] except HTTPError as error: - raise UpdateFailed from error + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from error # Verify that we have permission to read the sensors for sensor in self.devices: diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json index 8dc27ba259e..c5d9a11e49a 100644 --- a/homeassistant/components/lacrosse_view/strings.json +++ b/homeassistant/components/lacrosse_view/strings.json @@ -42,5 +42,10 @@ "name": "Wind chill" } } + }, + "exceptions": { + "update_error": { + "message": "Error updating data" + } } } diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 860156beb6c..7221fa4c071 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -165,3 +165,25 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( permissions={"read": True}, model="Test", ) +TEST_NO_READINGS_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "no_readings"}, + permissions={"read": True}, + model="Test", +) +TEST_OTHER_ERROR_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "some_other_error"}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index af92d0e64f1..0533dd2abee 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -83,6 +83,23 @@ async def test_http_error(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY + config_entry_2 = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry_2.add_to_hass(hass) + + # Start over, let get_devices succeed but get_sensor_status fail + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_sensor_status", side_effect=HTTPError), + ): + assert not await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 2 + assert entries[1].state is ConfigEntryState.SETUP_RETRY + async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 17ae56ed78d..e0dc1e5f35f 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -18,6 +18,8 @@ from . import ( TEST_MISSING_FIELD_DATA_SENSOR, TEST_NO_FIELD_SENSOR, TEST_NO_PERMISSION_SENSOR, + TEST_NO_READINGS_SENSOR, + TEST_OTHER_ERROR_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, TEST_UNITS_OVERRIDE_SENSOR, @@ -204,3 +206,57 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED assert hass.states.get("sensor.test_temperature").state == "unknown" + + +async def test_no_readings(hass: HomeAssistant) -> None: + """Test behavior when there are no readings.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_NO_READINGS_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.test_temperature").state == "unavailable" + + +async def test_other_error(hass: HomeAssistant) -> None: + """Test behavior when there is an error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_OTHER_ERROR_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY From 07c304125aec142087348f2937ef73946e381741 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:37:52 +0100 Subject: [PATCH 043/155] Add error handling to enphase_envoy select platform action (#136698) * Add error handling to enphase_envoy select platform action * Add translation key parameter to exception_handler decorator --- .../components/enphase_envoy/select.py | 4 +- tests/components/enphase_envoy/test_select.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 546470a19d5..42b47e5d793 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Return the state of the Enpower switch.""" return self.entity_description.value_fn(self.relay) + @exception_handler async def async_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) @@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) + @exception_handler async def async_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, option) diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index e13492c7f54..a81a06a3441 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -17,6 +18,7 @@ from homeassistant.components.enphase_envoy.select import ( from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -157,6 +159,46 @@ async def test_select_relay_modes( ) +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "action"), + [("envoy_metered_batt_relay", "NC1", "generator_action", "powered")], + indirect=["mock_envoy"], +) +async def test_update_dry_contact_actions_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + target: str, + relay: str, + action: str, +) -> None: + """Test select platform update dry contact action with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SELECT}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -197,6 +239,44 @@ async def test_select_storage_modes( mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +@pytest.mark.parametrize(("mode"), ["backup"]) +async def test_set_storage_modes_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, + mode: str, +) -> None: + """Test select platform set storage mode with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + mock_envoy.set_storage_mode.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ From 0a9d134f49170a6e0836c35f81b27212168efc87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 10:28:55 +0100 Subject: [PATCH 044/155] Make descriptions of `data` fields in notify actions UI-friendly (#138431) Also fixes a duplicated period at the end of the second string. --- homeassistant/components/notify/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index e832bfc248a..b33af360448 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -24,7 +24,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } }, @@ -56,7 +56,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } } From a8f4ab73aebb747adc95f33ced5092d6a8d64471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 13 Feb 2025 12:40:55 +0100 Subject: [PATCH 045/155] Bump hass-nabucasa from 0.90.0 to 0.91.0 (#138441) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7598dde6cf3..16d340a480b 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.90.0"], + "requirements": ["hass-nabucasa==0.91.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b35d5589182..b49409d9ce7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index c0d83b05f00..e693b6ec9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.90.0", + "hass-nabucasa==0.91.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 4afa122ba7d..7baea71e608 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c9d118adb8e..92d1a2a62ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 335326545a2..6e24129a0fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.conversation hassil==2.2.3 From 6a26d59142dfc14d718450ac2b3277ec0a722784 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Thu, 13 Feb 2025 05:45:09 -0600 Subject: [PATCH 046/155] Add night light brightness level setting to VeSync (#137544) --- homeassistant/components/vesync/__init__.py | 1 + homeassistant/components/vesync/const.py | 4 + homeassistant/components/vesync/select.py | 133 +++++++++++++++++++ homeassistant/components/vesync/strings.json | 10 ++ tests/components/vesync/common.py | 1 + tests/components/vesync/conftest.py | 45 ++++++- tests/components/vesync/test_init.py | 2 + tests/components/vesync/test_select.py | 54 ++++++++ 8 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vesync/select.py create mode 100644 tests/components/vesync/test_select.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 4951bdb2dc1..f9371d44507 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -27,6 +27,7 @@ PLATFORMS = [ Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 34454081567..897c8d2b745 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -29,6 +29,10 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py new file mode 100644 index 00000000000..c266985fc2b --- /dev/null +++ b/homeassistant/components/vesync/select.py @@ -0,0 +1,133 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import rgetattr +from .const import ( + DOMAIN, + NIGHT_LIGHT_LEVEL_BRIGHT, + NIGHT_LIGHT_LEVEL_DIM, + NIGHT_LIGHT_LEVEL_OFF, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + +VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { + 100: NIGHT_LIGHT_LEVEL_BRIGHT, + 50: NIGHT_LIGHT_LEVEL_DIM, + 0: NIGHT_LIGHT_LEVEL_OFF, +} + +HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +} + + +@dataclass(frozen=True, kw_only=True) +class VeSyncSelectEntityDescription(SelectEntityDescription): + """Class to describe a Vesync select entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + current_option_fn: Callable[[VeSyncBaseDevice], str] + select_option_fn: Callable[[VeSyncBaseDevice, str], bool] + + +SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "night_light"), + # The select_option service framework ensures that only options specified are + # accepted. ServiceValidationError gets raised for invalid value. + select_option_fn=lambda device, value: device.set_night_light_brightness( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + ), + # Reporting "off" as the choice for unhandled level. + current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add select entities.""" + + async_add_entities( + VeSyncSelectEntity(dev, description, coordinator) + for dev in devices + for description in SELECT_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncSelectEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSelectEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync select device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.device) + + async def async_select_option(self, option: str) -> None: + """Set an option.""" + if await self.hass.async_add_executor_job( + self.entity_description.select_option_fn, self.device, option + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 3eb2a0c3fd5..2232b16329b 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -56,6 +56,16 @@ "name": "Mist level" } }, + "select": { + "night_light_level": { + "name": "Night light level", + "state": { + "bright": "Bright", + "dim": "Dim", + "off": "[%key:common::state::off%]" + } + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ee9f9b94052..39a92778727 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -13,6 +13,7 @@ from tests.common import load_fixture, load_json_object_fixture ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" +ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 9ec7bd23fa5..df6ebbdf6e7 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -107,7 +107,7 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): - """Create a mock VeSync humidifier fixture.""" + """Create a mock VeSync Classic200S humidifier fixture.""" return Mock( VeSyncHumid200300S, cid="200s-humidifier", @@ -135,6 +135,34 @@ def humidifier_fixture(): ) +@pytest.fixture(name="humidifier_300s") +def humidifier_300s_fixture(): + """Create a mock VeSync Classic300S humidifier fixture.""" + return Mock( + VeSyncHumid200300S, + cid="300s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + device_type="Classic300S", + device_name="Humidifier 300s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + night_light=True, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) + + @pytest.fixture(name="humidifier_config_entry") async def humidifier_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config @@ -155,6 +183,21 @@ async def humidifier_config_entry( return entry +@pytest.fixture +async def install_humidifier_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager, + request: pytest.FixtureRequest, +) -> None: + """Create a mock VeSync config entry with the specified humidifier device.""" + + # Install the defined humidifier + manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index f1fb3931bf9..011545af2ae 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -56,6 +56,7 @@ async def test_async_setup_entry__no_devices( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -87,6 +88,7 @@ async def test_async_setup_entry__loads_fans( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py new file mode 100644 index 00000000000..30c83c89e0e --- /dev/null +++ b/tests/components/vesync/test_select.py @@ -0,0 +1,54 @@ +"""Tests for the select platform.""" + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_set_nightlight_level( + hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device +) -> None: + """Test set of night light level.""" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, + ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + }, + blocking=True, + ) + + # Assert that setter API was invoked with the expected translated value + humidifier_300s.set_night_light_brightness.assert_called_once_with( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + ) + # Assert that devices were refreshed + manager.update_all_devices.assert_called_once() + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: + """Test the state of night light level select entity.""" + + # The mocked device has night_light_brightness=50 which is "dim" + assert ( + hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state + == NIGHT_LIGHT_LEVEL_DIM + ) From e9138a427da8943c174205561f37a73944ce01d3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 13:00:38 +0100 Subject: [PATCH 047/155] Replace wrong description reference of isy994.send_node_command (#138385) --- homeassistant/components/isy994/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 86a1f14ff91..8872226daba 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -58,7 +58,7 @@ "services": { "send_raw_node_command": { "name": "Send raw node command", - "description": "[%key:component::isy994::options::step::init::description%]", + "description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that aren’t fully supported in Home Assistant yet, such as controls for many NodeServer nodes.", "fields": { "command": { "name": "Command", From 7021175e0da26a6f6144550c035349b2ecb6ff80 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:07:24 +0100 Subject: [PATCH 048/155] Simplify stage 1 in bootstrap (#137668) * Simplify stage 1 in bootstrap * Add timeouts to STAGE 0 * Fix test * Clarify pre import language * Remove timeout for frontend and recorder * Address review --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 132 ++++++++++++++------------------- tests/test_bootstrap.py | 14 ++-- tests/test_circular_imports.py | 14 ++-- 3 files changed, 67 insertions(+), 93 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 58150ae7926..7fd73af0053 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded") LOG_SLOW_STARTUP_INTERVAL = 60 SLOW_STARTUP_CHECK_INTERVAL = 1 +STAGE_0_SUBSTAGE_TIMEOUT = 60 STAGE_1_TIMEOUT = 120 STAGE_2_TIMEOUT = 300 WRAP_UP_TIMEOUT = 300 COOLDOWN_TIME = 60 - -DEBUGGER_INTEGRATIONS = {"debugpy"} - # Core integrations are unconditionally loaded CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} @@ -172,12 +170,27 @@ FRONTEND_INTEGRATIONS = { # add it here. "backup", } -RECORDER_INTEGRATIONS = { - # Setup after frontend - # To record data - "recorder", -} -DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf") +# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout. +# The substage containing recorder should have no timeout, as it could cancel a database migration. +# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts. +# The substages preceding it should also have no timeout, until we ensure that the recorder +# is not accidentally promoted as a dependency of any of the integrations in them. +# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode. +STAGE_0_INTEGRATIONS = ( + # Load logging and http deps as soon as possible + ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None), + # Setup frontend + ("frontend", FRONTEND_INTEGRATIONS, None), + # Setup recorder + ("recorder", {"recorder"}, None), + # Start up debuggers. Start these first in case they want to wait. + ("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT), + # Zeroconf is used for mdns resolution in aiohttp client helper. + ("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT), +) + +DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb") +# Stage 1 integrations are not to be preimported in bootstrap. STAGE_1_INTEGRATIONS = { # We need to make sure discovery integrations # update their deps before stage 2 integrations @@ -189,9 +202,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", - # Ensure supervisor is available - "hassio", } + DEFAULT_INTEGRATIONS = { # These integrations are set up unless recovery mode is activated. # @@ -232,22 +244,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = { # These integrations are set up if using the Supervisor "hassio", } + CRITICAL_INTEGRATIONS = { # Recovery mode is activated if these integrations fail to set up "frontend", } -SETUP_ORDER = ( - # Load logging and http deps as soon as possible - ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), - # Setup frontend - ("frontend", FRONTEND_INTEGRATIONS), - # Setup recorder - ("recorder", RECORDER_INTEGRATIONS), - # Start up debuggers. Start these first in case they want to wait. - ("debugger", DEBUGGER_INTEGRATIONS), -) - # # Storage keys we are likely to load during startup # in order of when we expect to load them. @@ -694,7 +696,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str: return deps_dir -@core.callback def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] @@ -890,69 +891,48 @@ async def _async_set_up_integrations( domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( hass, config ) + stage_2_domains = domains_to_setup.copy() # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) - pre_stage_domains = [ - (name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + *( + (name, domain_group & domains_to_setup, timeout) + for name, domain_group, timeout in STAGE_0_INTEGRATIONS + ), + ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), ] - # calculate what components to setup in what stage - stage_1_domains: set[str] = set() + _LOGGER.info("Setting up stage 0 and 1") + for name, domain_group, timeout in stage_0_and_1_domains: + if not domain_group: + continue - # Find all dependencies of any dependency of any stage 1 integration that - # we plan on loading and promote them to stage 1. This is done only to not - # get misleading log messages - deps_promotion: set[str] = STAGE_1_INTEGRATIONS - while deps_promotion: - old_deps_promotion = deps_promotion - deps_promotion = set() + _LOGGER.info("Setting up %s: %s", name, domain_group) + to_be_loaded = domain_group.copy() + to_be_loaded.update( + dep + for domain in domain_group + if (integration := integration_cache.get(domain)) is not None + for dep in integration.all_dependencies + ) + async_set_domains_to_be_loaded(hass, to_be_loaded) + stage_2_domains -= to_be_loaded - for domain in old_deps_promotion: - if domain not in domains_to_setup or domain in stage_1_domains: - continue - - stage_1_domains.add(domain) - - if (dep_itg := integration_cache.get(domain)) is None: - continue - - deps_promotion.update(dep_itg.all_dependencies) - - stage_2_domains = domains_to_setup - stage_1_domains - - for name, domain_group in pre_stage_domains: - if domain_group: - stage_2_domains -= domain_group - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( - dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies - ) - async_set_domains_to_be_loaded(hass, to_be_loaded) + if timeout is None: await _async_setup_multi_components(hass, domain_group, config) - - # Enables after dependencies when setting up stage 1 domains - async_set_domains_to_be_loaded(hass, stage_1_domains) - - # Start setup - if stage_1_domains: - _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - try: - async with hass.timeout.async_timeout( - STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_1_domains, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for stage 1 waiting on %s - moving forward", - hass._active_tasks, # noqa: SLF001 - ) + else: + try: + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, domain_group, config) + except TimeoutError: + _LOGGER.warning( + "Setup timed out for %s waiting on %s - moving forward", + name, + hass._active_tasks, # noqa: SLF001 + ) # Add after dependencies when setting up stage 2 domains async_set_domains_to_be_loaded(hass, stage_2_domains) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4317df6cf4a..d554ca9449a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1090,7 +1090,7 @@ async def test_tasks_logged_that_block_stage_1( patch.object(bootstrap, "STAGE_1_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), patch.object( - bootstrap, "STAGE_1_INTEGRATIONS", [*original_stage_1, "normal_integration"] + bootstrap, "STAGE_1_INTEGRATIONS", {*original_stage_1, "normal_integration"} ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) @@ -1373,11 +1373,11 @@ async def test_pre_import_no_requirements(hass: HomeAssistant) -> None: @pytest.mark.timeout(20) -async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: - """Test that the bootstrap does not preload stage 1 integrations. +async def test_bootstrap_does_not_preimport_stage_1_integrations() -> None: + """Test that the bootstrap does not preimport stage 1 integrations. If this test fails it means that stage1 integrations are being - loaded too soon and will not get their requirements updated + imported too soon and will not get their requirements updated before they are loaded at runtime. """ @@ -1391,13 +1391,9 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() - disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() - # zeroconf is a top level dep now - disallowed_integrations.remove("zeroconf") - # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in disallowed_integrations: + for integration in bootstrap.STAGE_1_INTEGRATIONS: assert f"homeassistant.components.{integration}" not in decoded_stdout diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index dfdee65b2b0..d6e730aae5e 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -7,11 +7,8 @@ import pytest from homeassistant.bootstrap import ( CORE_INTEGRATIONS, - DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, - FRONTEND_INTEGRATIONS, - LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - RECORDER_INTEGRATIONS, + STAGE_0_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -21,11 +18,12 @@ from homeassistant.bootstrap import ( "component", sorted( { - *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - *FRONTEND_INTEGRATIONS, - *RECORDER_INTEGRATIONS, + *( + domain + for name, domains, timeout in STAGE_0_INTEGRATIONS + for domain in domains + ), *STAGE_1_INTEGRATIONS, *DEFAULT_INTEGRATIONS, } From 82074a894075bd7795c057274d5a9e9142238295 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Thu, 13 Feb 2025 16:36:07 +0100 Subject: [PATCH 049/155] Starlink migration to `StarlinkConfigEntry` (#137896) * refactor: Utilize custom StarlinkConfigEntry * fix: ruff-format * fix: Init tests * fix: StarlinkConfigEntry in coordinator after recent PRs * fix: CONF_IP_ADDRESS constant * fix: After merge clean up * fix: Naming conventions * feat: Add runtime_data into init test * refactor: Remove runtime_data assert in unload entry test --- homeassistant/components/starlink/__init__.py | 26 ++++++++----------- .../components/starlink/binary_sensor.py | 10 +++---- homeassistant/components/starlink/button.py | 11 +++----- .../components/starlink/coordinator.py | 6 +++-- .../components/starlink/device_tracker.py | 11 +++----- .../components/starlink/diagnostics.py | 9 +++---- homeassistant/components/starlink/sensor.py | 11 +++----- homeassistant/components/starlink/switch.py | 11 +++----- homeassistant/components/starlink/time.py | 11 +++----- tests/components/starlink/test_init.py | 4 +-- 10 files changed, 43 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 4528a35858c..0c512bb21c5 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -19,21 +17,19 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Set up Starlink from a config entry.""" - coordinator = StarlinkUpdateCoordinator(hass, entry) + config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry) + await config_entry.runtime_data.async_config_entry_first_refresh() - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index f5eaf2baba0..e06e79009c3 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -10,26 +10,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkBinarySensorEntity(coordinator, description) + StarlinkBinarySensorEntity(config_entry.runtime_data, description) for description in BINARY_SENSORS ) diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index dc23e31d8d2..15f35659b49 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -10,26 +10,23 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkButtonEntity(coordinator, description) for description in BUTTONS + StarlinkButtonEntity(config_entry.runtime_data, description) + for description in BUTTONS ) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 4ae771c9582..02d51cd805e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator] + @dataclass class StarlinkData: @@ -51,9 +53,9 @@ class StarlinkData: class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Coordinates updates between all Starlink sensors defined in this file.""" - config_entry: ConfigEntry + config_entry: StarlinkConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS]) self.history_stats_start = None diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 53e7ab1cee0..dbe31947b55 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -8,25 +8,22 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_ALTITUDE, DOMAIN -from .coordinator import StarlinkData +from .const import ATTR_ALTITUDE +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkDeviceTrackerEntity(coordinator, description) + StarlinkDeviceTrackerEntity(config_entry.runtime_data, description) for description in DEVICE_TRACKERS ) diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index c619458b1dd..543fe9d8dde 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -4,18 +4,15 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry TO_REDACT = {"id", "latitude", "longitude", "altitude"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, config_entry: StarlinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for Starlink config entries.""" - coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(asdict(coordinator.data), TO_REDACT) + return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index dadbf8a061a..d07e8174b27 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -28,21 +27,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import now -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSensorEntity(coordinator, description) for description in SENSORS + StarlinkSensorEntity(config_entry.runtime_data, description) + for description in SENSORS ) diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 51603850690..c6dc237643e 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -11,25 +11,22 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSwitchEntity(coordinator, description) for description in SWITCHES + StarlinkSwitchEntity(config_entry.runtime_data, description) + for description in SWITCHES ) diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 3540123e1eb..9f564333218 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -8,26 +8,23 @@ from datetime import UTC, datetime, time, tzinfo import math from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all time entities for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkTimeEntity(coordinator, description) for description in TIMES + StarlinkTimeEntity(config_entry.runtime_data, description) + for description in TIMES ) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 7e04c21562a..f15a80771cf 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -33,8 +33,9 @@ async def test_successful_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.runtime_data + assert entry.runtime_data.data assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] async def test_unload_entry(hass: HomeAssistant) -> None: @@ -59,4 +60,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert entry.entry_id not in hass.data[DOMAIN] From a03c5880021ad510b262d55650332c34721b7822 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:54:29 +0100 Subject: [PATCH 050/155] Mark entity-device-class as done for motionmount integration (#138459) All entities where a device class is available have a device class --- homeassistant/components/motionmount/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index f8fee8739e9..2648355c3af 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: Single device per config entry entity-category: todo - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done From d4c5479e503ae17d71e9edbe7e09b2fe57271ef1 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Thu, 13 Feb 2025 17:14:56 +0100 Subject: [PATCH 051/155] Fix Tuya unsupported cameras (#136960) --- homeassistant/components/tuya/camera.py | 3 ++ homeassistant/components/tuya/light.py | 14 ++++++ homeassistant/components/tuya/number.py | 9 ++++ homeassistant/components/tuya/select.py | 34 ++++++++++++++ homeassistant/components/tuya/sensor.py | 23 ++++++++++ homeassistant/components/tuya/siren.py | 7 +++ homeassistant/components/tuya/switch.py | 59 +++++++++++++++++++++++++ 7 files changed, 149 insertions(+) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index b07b9e9959e..c04a8a043dc 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -20,6 +20,9 @@ CAMERAS: tuple[str, ...] = ( # Smart Camera (including doorbells) # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sp", + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj", ) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7f4a964f47e..40d0fd73f0e 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -261,6 +261,20 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaLightEntityDescription( + key=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + ), + TuyaLightEntityDescription( + key=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 4e98cf34d4d..ce1f434bcdd 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -174,6 +174,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + NumberEntityDescription( + key=DPCode.BASIC_DEVICE_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 766cdd295f1..0ae49cd127e 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -128,6 +128,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SelectEntityDescription( + key=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="ipc_work_mode", + ), + SelectEntityDescription( + key=DPCode.DECIBEL_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="decibel_sensitivity", + ), + SelectEntityDescription( + key=DPCode.RECORD_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="record_mode", + ), + SelectEntityDescription( + key=DPCode.BASIC_NIGHTVISION, + entity_category=EntityCategory.CONFIG, + translation_key="basic_nightvision", + ), + SelectEntityDescription( + key=DPCode.BASIC_ANTI_FLICKER, + entity_category=EntityCategory.CONFIG, + translation_key="basic_anti_flicker", + ), + SelectEntityDescription( + key=DPCode.MOTION_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="motion_sensitivity", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index cb7602e24fe..76825e9c814 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -632,6 +632,29 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaSensorEntityDescription( + key=DPCode.SENSOR_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SENSOR_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIRELESS_ELECTRICITY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Fingerbot "szjqr": BATTERY_SENSORS, # Solar Light diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 310385df93d..9c60f7bcaac 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -44,6 +44,13 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SirenEntityDescription( + key=DPCode.SIREN_SWITCH, + ), + ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d0192b41ee6..519a9e83606 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -509,6 +509,65 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SwitchEntityDescription( + key=DPCode.WIRELESS_BATTERYLOCK, + translation_key="battery_lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CRY_DETECTION_SWITCH, + translation_key="cry_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.DECIBEL_SWITCH, + translation_key="sound_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.RECORD_SWITCH, + translation_key="video_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_RECORD, + translation_key="motion_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_PRIVATE, + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_FLIP, + translation_key="flip", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_OSD, + translation_key="time_watermark", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_WDR, + translation_key="wide_dynamic_range", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_TRACKING, + translation_key="motion_tracking", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_SWITCH, + translation_key="motion_alarm", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( From bf27eeb861e33ae21a6920de036e79542b31ecbc Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:46:50 -0500 Subject: [PATCH 052/155] Add sonos_websocket to Sonos loggers (#138470) --- homeassistant/components/sonos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bfdf0da9dbb..bb3d99c4c93 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", - "loggers": ["soco"], + "loggers": ["soco", "sonos_websocket"], "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], "ssdp": [ { From 2ea648f8aee3a264700b8c09b32a0d43fa8f6218 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 19:55:04 +0100 Subject: [PATCH 053/155] Replace `config.yaml` with correct `configuration.yaml` in folder_watcher (#138434) --- homeassistant/components/folder_watcher/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json index da1e3c1962a..5b1f72bf254 100644 --- a/homeassistant/components/folder_watcher/strings.json +++ b/homeassistant/components/folder_watcher/strings.json @@ -36,11 +36,11 @@ "issues": { "import_failed_not_allowed_path": { "title": "The Folder Watcher YAML configuration could not be imported", - "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue." + "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue." }, "setup_not_allowed_path": { "title": "The Folder Watcher configuration for {path} could not start", - "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue." + "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue." } }, "entity": { From ab2e075b410cbeeaacd6c3e0241c340f0af004c6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 13 Feb 2025 11:35:58 -0800 Subject: [PATCH 054/155] Bump opower to 0.9.0 (#138433) Co-authored-by: Shay Levy --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index d168cba5752..2da4511c0aa 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.9"] + "requirements": ["opower==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 92d1a2a62ab..ba5aeee25df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1595,7 +1595,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e24129a0fe..3ea50ac1d32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 From bbbad90ca29b9e9a356fe166fcf586ede8cf8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Feb 2025 14:17:06 -0600 Subject: [PATCH 055/155] Fix race configuring zeroconf (#138425) --- homeassistant/bootstrap.py | 4 ++ homeassistant/components/network/__init__.py | 19 ++++++++- homeassistant/components/network/const.py | 2 - homeassistant/components/network/network.py | 13 +++++- homeassistant/components/zeroconf/__init__.py | 24 ++++++----- tests/components/zeroconf/test_init.py | 40 ++++++++++++++++--- tests/conftest.py | 34 +++++++++++----- 7 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7fd73af0053..7c5cb7dce4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -150,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = { "isal", # Set log levels "logger", + # Ensure network config is available + # before hassio or any other integration is + # loaded that might create an aiohttp client session + "network", # Error logging "system_log", "sentry", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 10046f75127..200cce86997 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -20,7 +20,7 @@ from .const import ( PUBLIC_TARGET_IP, ) from .models import Adapter -from .network import Network, async_get_network +from .network import Network, async_get_loaded_network, async_get_network _LOGGER = logging.getLogger(__name__) @@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: return network.adapters +@callback +def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]: + """Get the network adapter configuration.""" + return async_get_loaded_network(hass).adapters + + @bind_hass async def async_get_source_ip( hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED @@ -74,7 +80,14 @@ async def async_get_enabled_source_ips( hass: HomeAssistant, ) -> list[IPv4Address | IPv6Address]: """Build the list of enabled source ips.""" - adapters = await async_get_adapters(hass) + return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass)) + + +@callback +def async_get_enabled_source_ips_from_adapters( + adapters: list[Adapter], +) -> list[IPv4Address | IPv6Address]: + """Build the list of enabled source ips.""" sources: list[IPv4Address | IPv6Address] = [] for adapter in adapters: if not adapter["enabled"]: @@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_commands, ) + await async_get_network(hass) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 120ae9dfd7c..d8c8858be72 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -12,8 +12,6 @@ DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" STORAGE_VERSION: Final = 1 -DATA_NETWORK: Final = "network" - ATTR_ADAPTERS: Final = "adapters" ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index 4158307bb1a..db25bedcaea 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_CONFIGURED_ADAPTERS, - DATA_NETWORK, DEFAULT_CONFIGURED_ADAPTERS, + DOMAIN, STORAGE_KEY, STORAGE_VERSION, ) @@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada _LOGGER = logging.getLogger(__name__) +DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN) -@singleton(DATA_NETWORK) + +@callback +def async_get_loaded_network(hass: HomeAssistant) -> Network: + """Get network singleton.""" + return hass.data[DATA_NETWORK] + + +@singleton(DOMAIN) async def async_get_network(hass: HomeAssistant) -> Network: """Get network singleton.""" network = Network(hass) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b748006336c..e80b6b8cfdb 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -141,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: return _async_get_instance(hass) -def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: +def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**zcargs) + zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) install_multiple_zeroconf_catcher(zeroconf) @@ -175,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool: ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {"ip_version": IPVersion.V4Only} - - adapters = await network.async_get_adapters(hass) - +def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]: + """Get zeroconf arguments from config.""" + zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only} + adapters = network.async_get_loaded_adapters(hass) ipv6 = False if _async_zc_has_functional_dual_stack(): if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): @@ -195,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: zc_args["interfaces"] = [ str(source_ip) - for source_ip in await network.async_get_enabled_source_ips(hass) + for source_ip in network.async_get_enabled_source_ips_from_adapters( + adapters + ) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) and not ( @@ -207,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: and zc_args["ip_version"] == IPVersion.V6Only ) ] + return zc_args - aio_zc = _async_get_instance(hass, **zc_args) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Zeroconf and make Home Assistant discoverable.""" + aio_zc = _async_get_instance(hass) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 3586f54a59a..56262600511 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1090,7 +1090,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( @@ -1178,7 +1178,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1212,7 +1212,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1263,7 +1263,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1292,7 +1292,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1310,6 +1310,36 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_detect_interfaces_explicitly_before_setup( + hass: HomeAssistant, +) -> None: + """Test interfaces are explicitly set with IPv6 before setup is called.""" + with ( + patch("homeassistant.components.zeroconf.sys.platform", "linux"), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch.object(hass.config_entries.flow, "async_init"), + patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch( + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ), + ): + # Call before async_setup has been called + await zeroconf.async_get_async_instance(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_zc.mock_calls[0] == call( + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef%3"], + ip_version=IPVersion.All, + ) + + async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" diff --git a/tests/conftest.py b/tests/conftest.py index de627925941..7905439028c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1180,15 +1180,31 @@ async def mqtt_mock_entry( @pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None]: """Mock network.""" - with patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[ - Mock( - nice_name="eth0", - ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], - index=0, - ) - ], + with ( + patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], + ), + patch( + "homeassistant.components.network.async_get_loaded_adapters", + return_value=[ + { + "auto": True, + "default": True, + "enabled": True, + "index": 0, + "ipv4": [{"address": "10.10.10.10", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + } + ], + ), ): yield From d6b7762dd65c7814f7b816a28f589bb0a3899233 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 13 Feb 2025 22:13:19 +0100 Subject: [PATCH 056/155] Upgrade paho-mqtt API to v2 (#137613) * Upgrade paho-mqtt API to v2 * Refactor on_connect callback * Add tests * Fix Tasmota tests --- homeassistant/components/mqtt/async_client.py | 15 +++- homeassistant/components/mqtt/client.py | 86 ++++++++++++------- homeassistant/components/mqtt/config_flow.py | 12 +-- tests/common.py | 19 ++++ tests/components/mqtt/test_client.py | 83 ++++++++++-------- tests/components/mqtt/test_config_flow.py | 8 +- tests/components/mqtt/test_init.py | 10 ++- tests/components/tasmota/test_common.py | 14 +-- tests/conftest.py | 17 ++-- 9 files changed, 171 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 5f90136df44..0467eb3a289 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -6,7 +6,14 @@ from functools import lru_cache from types import TracebackType from typing import Self -from paho.mqtt.client import Client as MQTTClient +from paho.mqtt.client import ( + CallbackOnConnect_v2, + CallbackOnDisconnect_v2, + CallbackOnPublish_v2, + CallbackOnSubscribe_v2, + CallbackOnUnsubscribe_v2, + Client as MQTTClient, +) _MQTT_LOCK_COUNT = 7 @@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient): that is not needed since we are running in an async event loop. """ + on_connect: CallbackOnConnect_v2 + on_disconnect: CallbackOnDisconnect_v2 + on_publish: CallbackOnPublish_v2 + on_subscribe: CallbackOnSubscribe_v2 + on_unsubscribe: CallbackOnUnsubscribe_v2 + def setup(self) -> None: """Set up the client. diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3aca566dbfc..af62851e15b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -311,8 +311,8 @@ class MqttClientSetup: client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( - mqtt.CallbackAPIVersion.VERSION1, - client_id, + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, protocol=proto, transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, @@ -476,9 +476,9 @@ class MQTT: mqttc.on_connect = self._async_mqtt_on_connect mqttc.on_disconnect = self._async_mqtt_on_disconnect mqttc.on_message = self._async_mqtt_on_message - mqttc.on_publish = self._async_mqtt_on_callback - mqttc.on_subscribe = self._async_mqtt_on_callback - mqttc.on_unsubscribe = self._async_mqtt_on_callback + mqttc.on_publish = self._async_mqtt_on_publish + mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe + mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe # suppress exceptions at callback mqttc.suppress_exceptions = True @@ -498,7 +498,7 @@ class MQTT: def _async_reader_callback(self, client: mqtt.Client) -> None: """Handle reading data from the socket.""" if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) @callback def _async_start_misc_periodic(self) -> None: @@ -593,7 +593,7 @@ class MQTT: def _async_writer_callback(self, client: mqtt.Client) -> None: """Handle writing data to the socket.""" if (status := client.loop_write()) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) def _on_socket_register_write( self, client: mqtt.Client, userdata: Any, sock: SocketType @@ -983,9 +983,9 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, - _flags: dict[str, int], - result_code: int, - properties: mqtt.Properties | None = None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """On connect callback. @@ -993,19 +993,20 @@ class MQTT: message. """ # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - if result_code != mqtt.CONNACK_ACCEPTED: - if result_code in ( - mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD, - mqtt.CONNACK_REFUSED_NOT_AUTHORIZED, - ): + if reason_code.is_failure: + # 24: Continue authentication + # 25: Re-authenticate + # 134: Bad user name or password + # 135: Not authorized + # 140: Bad authentication method + if reason_code.value in (24, 25, 134, 135, 140): self._should_reconnect = False self.hass.async_create_task(self.async_disconnect()) self.config_entry.async_start_reauth(self.hass) _LOGGER.error( "Unable to connect to the MQTT broker: %s", - mqtt.connack_string(result_code), + reason_code.getName(), # type: ignore[no-untyped-call] ) self._async_connection_result(False) return @@ -1016,7 +1017,7 @@ class MQTT: "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) birth: dict[str, Any] @@ -1153,18 +1154,32 @@ class MQTT: self._mqtt_data.state_write_requests.process_write_state_requests(msg) @callback - def _async_mqtt_on_callback( + def _async_mqtt_on_publish( self, _mqttc: mqtt.Client, _userdata: None, mid: int, - _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None, - _properties_reason: mqtt.ReasonCodes | None = None, + _reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None, ) -> None: + """Publish callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_subscribe_unsubscribe( + self, + _mqttc: mqtt.Client, + _userdata: None, + mid: int, + _reason_code: list[mqtt.ReasonCode], + _properties: mqtt.Properties | None, + ) -> None: + """Subscribe / Unsubscribe callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_callback(self, mid: int) -> None: """Publish / Subscribe / Unsubscribe callback.""" - # The callback signature for on_unsubscribe is different from on_subscribe - # see https://github.com/eclipse/paho.mqtt.python/issues/687 - # properties and reason codes are not used in Home Assistant future = self._async_get_mid_future(mid) if future.done() and (future.cancelled() or future.exception()): # Timed out or cancelled @@ -1180,19 +1195,28 @@ class MQTT: self._pending_operations[mid] = future return future + @callback + def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: + """Handle a callback exception.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + _LOGGER.warning( + "Error returned from MQTT server: %s", + mqtt.error_string(status), + ) + @callback def _async_mqtt_on_disconnect( self, _mqttc: mqtt.Client, _userdata: None, - result_code: int, + _disconnect_flags: mqtt.DisconnectFlags, + reason_code: mqtt.ReasonCode, properties: mqtt.Properties | None = None, ) -> None: """Disconnected callback.""" - self._async_on_disconnect(result_code) - - @callback - def _async_on_disconnect(self, result_code: int) -> None: if not self.connected: # This function is re-entrant and may be called multiple times # when there is a broken pipe error. @@ -1203,11 +1227,11 @@ class MQTT: self.connected = False async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) _LOGGER.log( - logging.INFO if result_code == 0 else logging.DEBUG, + logging.INFO if reason_code == 0 else logging.DEBUG, "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) @callback diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a9d417fc783..22568b0f2b8 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1023,14 +1023,14 @@ def try_connection( result: queue.Queue[bool] = queue.Queue(maxsize=1) def on_connect( - client_: mqtt.Client, - userdata: None, - flags: dict[str, Any], - result_code: int, - properties: mqtt.Properties | None = None, + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """Handle connection result.""" - result.put(result_code == mqtt.CONNACK_ACCEPTED) + result.put(not reason_code.is_failure) client.on_connect = on_connect diff --git a/tests/common.py b/tests/common.py index 65e84bc6f00..4d767f0611c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -410,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten return intents +class MockMqttReasonCode: + """Class to fake a MQTT ReasonCode.""" + + value: int + is_failure: bool + + def __init__( + self, value: int = 0, is_failure: bool = False, name: str = "Success" + ) -> None: + """Initialize the mock reason code.""" + self.value = value + self.is_failure = is_failure + self._name = name + + def getName(self) -> str: + """Return the name of the reason code.""" + return self._name + + @callback def async_fire_mqtt_message( hass: HomeAssistant, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 2faa9310548..b526d70490b 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -32,6 +32,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, ) @@ -94,7 +95,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mqtt_client.on_connect, mqtt_client, None, 0, 0, 0 + mqtt_client.on_connect, mqtt_client, None, 0, MockMqttReasonCode() ), ) mqtt_client.publish = MagicMock(return_value=FakeInfo()) @@ -119,7 +120,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: ) await asyncio.sleep(0) # Simulate late ACK callback from client with mid 100 - mqtt_client.on_publish(0, 0, 100) + mqtt_client.on_publish(0, 0, 100, MockMqttReasonCode(), None) # disconnect the MQTT client await hass.async_stop() await hass.async_block_till_done() @@ -778,10 +779,10 @@ async def test_replaying_payload_same_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() # Simulate a (retained) message played back after reconnecting @@ -908,10 +909,10 @@ async def test_replaying_payload_wildcard_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() @@ -1045,7 +1046,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) # Test to subscribe orther topic while the client is not connected await mqtt.async_subscribe(hass, "test/other", record_calls) @@ -1053,7 +1054,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) @@ -1089,10 +1090,10 @@ async def test_restore_all_active_subscriptions_on_reconnect( unsub() assert mqtt_client_mock.unsubscribe.call_count == 0 - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) # wait for cooldown await mock_debouncer.wait() @@ -1160,27 +1161,37 @@ async def test_logs_error_if_no_connect_broker( ) -> None: """Test for setup failure if connection to broker is missing.""" mqtt_client_mock = setup_with_birth_msg_client_mock - # test with rc = 3 -> broker unavailable - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, 3) - await hass.async_block_till_done() - assert ( - "Unable to connect to the MQTT broker: Connection Refused: broker unavailable." - in caplog.text + # test with reason code = 136 -> server unavailable + mqtt_client_mock.on_disconnect(Mock(), None, None, MockMqttReasonCode()) + mqtt_client_mock.on_connect( + Mock(), + None, + None, + MockMqttReasonCode(value=136, is_failure=True, name="Server unavailable"), ) + await hass.async_block_till_done() + assert "Unable to connect to the MQTT broker: Server unavailable" in caplog.text -@pytest.mark.parametrize("return_code", [4, 5]) +@pytest.mark.parametrize( + "reason_code", + [ + MockMqttReasonCode( + value=134, is_failure=True, name="Bad user name or password" + ), + MockMqttReasonCode(value=135, is_failure=True, name="Not authorized"), + ], +) async def test_triggers_reauth_flow_if_auth_fails( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient, - return_code: int, + reason_code: MockMqttReasonCode, ) -> None: """Test re-auth is triggered if authentication is failing.""" mqtt_client_mock = setup_with_birth_msg_client_mock # test with rc = 4 -> CONNACK_REFUSED_NOT_AUTHORIZED and 5 -> CONNACK_REFUSED_BAD_USERNAME_PASSWORD - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, return_code) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode(), None) + mqtt_client_mock.on_connect(Mock(), None, None, reason_code) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -1197,7 +1208,9 @@ async def test_handle_mqtt_on_callback( mqtt_client_mock = setup_with_birth_msg_client_mock with patch.object(mqtt_client_mock, "get_mid", return_value=100): # Simulate an ACK for mid == 100, this will call mqtt_mock._async_get_mid_future(mid) - mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) + mqtt_client_mock.on_publish( + mqtt_client_mock, None, 100, MockMqttReasonCode(), None + ) await hass.async_block_till_done() # Make sure the ACK has been received await hass.async_block_till_done() @@ -1219,7 +1232,7 @@ async def test_handle_mqtt_on_callback_after_cancellation( # Simulate the mid future getting a cancellation mqtt_mock()._async_get_mid_future(101).cancel() # Simulate an ACK for mid == 101, being received after the cancellation - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1236,7 +1249,7 @@ async def test_handle_mqtt_on_callback_after_timeout( # Simulate the mid future getting a timeout mqtt_mock()._async_get_mid_future(101).set_exception(asyncio.TimeoutError) # Simulate an ACK for mid == 101, being received after the timeout - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1388,7 +1401,7 @@ async def test_handle_mqtt_timeout_on_callback( mock_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ), ) @@ -1777,12 +1790,12 @@ async def test_mqtt_subscribes_topics_on_connect( await mqtt.async_subscribe(hass, "still/pending", record_calls, 1) await mock_debouncer.wait() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1837,12 +1850,12 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( # Assert the initial wildcard topic subscription order _assert_subscription_order() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() # Assert the wildcard topic subscription order after a reconnect @@ -1868,12 +1881,12 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( assert (f"homeassistant/{component}/+/config", 0) not in subscribe_calls assert (f"homeassistant/{component}/+/+/config", 0) not in subscribe_calls - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1968,7 +1981,7 @@ async def test_auto_reconnect( mqtt_client_mock.reconnect.reset_mock() mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() mqtt_client_mock.reconnect.side_effect = exception("foo") @@ -1989,7 +2002,7 @@ async def test_auto_reconnect( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() async_fire_time_changed( @@ -2031,7 +2044,7 @@ async def test_server_sock_connect_and_disconnect( mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_CONN_LOST mqtt_client_mock.on_socket_unregister_write(mqtt_client_mock, None, client) mqtt_client_mock.on_socket_close(mqtt_client_mock, None, client) - mqtt_client_mock.on_disconnect(mqtt_client_mock, None, client) + mqtt_client_mock.on_disconnect(mqtt_client_mock, None, None, MockMqttReasonCode()) await hass.async_block_till_done() mock_debouncer.clear() unsub() @@ -2169,4 +2182,4 @@ async def test_loop_write_failure( # Final for the disconnect callback await hass.async_block_till_done() - assert "Disconnected from MQTT server test-broker:1883" in caplog.text + assert "Error returned from MQTT server: The connection was lost." in caplog.text diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 1a4ca4bcf19..de70fd32763 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -143,16 +143,16 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient]: def loop_start(): """Simulate connect on loop start.""" - mock_client().on_connect(mock_client, None, None, 0) + mock_client().on_connect(mock_client, None, None, MockMqttReasonCode(), None) def _subscribe(topic, qos=0): mid = get_mid() - mock_client().on_subscribe(mock_client, 0, mid) + mock_client().on_subscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) def _unsubscribe(topic): mid = get_mid() - mock_client().on_unsubscribe(mock_client, 0, mid) + mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) with patch( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b2dd3d048ec..af9975de1ea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -45,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -1572,6 +1573,7 @@ async def test_subscribe_connection_status( setup_with_birth_msg_client_mock: MqttMockPahoClient, ) -> None: """Test connextion status subscription.""" + mqtt_client_mock = setup_with_birth_msg_client_mock mqtt_connected_calls_callback: list[bool] = [] mqtt_connected_calls_async: list[bool] = [] @@ -1589,7 +1591,7 @@ async def test_subscribe_connection_status( assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1603,12 +1605,12 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1618,7 +1620,7 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 4d2c821fff4..674ae316ecc 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -27,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_fire_mqtt_message +from tests.common import MockMqttReasonCode, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient, WebSocketGenerator DEFAULT_CONFIG = { @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( # Reconnected to MQTT server -> state still unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -226,7 +226,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -235,7 +235,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Reconnected to MQTT server -> state no longer unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -478,7 +478,7 @@ async def help_test_availability_poll_state( # Disconnected from MQTT server mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -486,7 +486,7 @@ async def help_test_availability_poll_state( # Reconnected to MQTT server mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index 7905439028c..7d9fa7eda2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,7 @@ from .common import ( # noqa: E402, isort:skip CLIENT_ID, INSTANCES, MockConfigEntry, + MockMqttReasonCode, MockUser, async_fire_mqtt_message, async_test_home_assistant, @@ -969,17 +970,23 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: def _async_fire_mqtt_message(topic, payload, qos, retain): async_fire_mqtt_message(hass, topic, payload or b"", qos, retain) mid = get_mid() - hass.loop.call_soon(mock_client.on_publish, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None + ) return FakeInfo(mid) def _subscribe(topic, qos=0): mid = get_mid() - hass.loop.call_soon(mock_client.on_subscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _unsubscribe(topic): mid = get_mid() - hass.loop.call_soon(mock_client.on_unsubscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_unsubscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _connect(*args, **kwargs): @@ -988,7 +995,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: # the behavior. mock_client.reconnect() hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ) mock_client.on_socket_open( mock_client, None, Mock(fileno=Mock(return_value=-1)) @@ -1065,7 +1072,7 @@ async def _mqtt_mock_entry( # connected set to True to get a more realistic behavior when subscribing mock_mqtt_instance.connected = True - mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) + mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, MockMqttReasonCode()) async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() From 621bcccef7c60428de7f7f1d5b3bca07317ce0ec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 13 Feb 2025 22:51:14 +0100 Subject: [PATCH 057/155] Remove scan interval option from Synology DSM (#138490) remove scan interval option --- homeassistant/components/synology_dsm/__init__.py | 6 +++++- homeassistant/components/synology_dsm/config_flow.py | 9 --------- homeassistant/components/synology_dsm/const.py | 1 - homeassistant/components/synology_dsm/coordinator.py | 11 +---------- homeassistant/components/synology_dsm/strings.json | 2 -- tests/components/synology_dsm/test_config_flow.py | 3 --- tests/components/synology_dsm/test_init.py | 3 +++ 7 files changed, 9 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0b8b8731f8f..97095f5d299 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, ) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) # Continue setup api = SynoApi(hass, entry) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index b4453366718..58784862305 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -33,14 +33,12 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import callback 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.selector import ( SelectOptionDict, @@ -67,7 +65,6 @@ from .const import ( DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, - DEFAULT_SCAN_INTERVAL, DEFAULT_SNAPSHOT_QUALITY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL, @@ -458,12 +455,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): data_schema = vol.Schema( { - vol.Required( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, vol.Required( CONF_SNAPSHOT_QUALITY, default=self.config_entry.options.get( diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index dbee85b99d6..8fb436e8fa6 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -48,7 +48,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options -DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED DEFAULT_BACKUP_PATH = "ha_backup" diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 30d1260ef32..1b3e21090b8 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -14,14 +14,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi, raise_config_entry_auth_error from .const import ( - DEFAULT_SCAN_INTERVAL, SIGNAL_CAMERA_SOURCE_CHANGED, SYNOLOGY_AUTH_FAILED_EXCEPTIONS, SYNOLOGY_CONNECTION_EXCEPTIONS, @@ -122,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" - super().__init__( - hass, - entry, - api, - timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) + super().__init__(hass, entry, api, timedelta(minutes=15)) @async_re_login_on_expired async def _async_update_data(self) -> None: diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index d6d40be3fea..c14f8da1037 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -68,8 +68,6 @@ "step": { "init": { "data": { - "scan_interval": "Minutes between scans", - "timeout": "Timeout (seconds)", "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b63ce6c2e18..b25cf7a81ac 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -27,7 +27,6 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -681,14 +680,12 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0, CONF_BACKUP_PATH: "my_nackup_path", CONF_BACKUP_SHARE: "/ha_backup", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 assert config_entry.options[CONF_BACKUP_PATH] == "my_nackup_path" assert config_entry.options[CONF_BACKUP_SHARE] == "/ha_backup" diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 7eaafc98437..7fe58719aa4 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -108,6 +109,7 @@ async def test_config_entry_migrations( CONF_PASSWORD: PASSWORD, CONF_MAC: MACS[0], }, + options={CONF_SCAN_INTERVAL: 30}, ) entry.add_to_hass(hass) @@ -118,5 +120,6 @@ async def test_config_entry_migrations( assert await hass.config_entries.async_setup(entry.entry_id) assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert CONF_SCAN_INTERVAL not in entry.options assert entry.options[CONF_BACKUP_SHARE] is None assert entry.options[CONF_BACKUP_PATH] is None From 00e98954e4ea8c41e043a50420e9e944c2f59e80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 14 Feb 2025 01:52:33 +0200 Subject: [PATCH 058/155] Bump aiowebostv to 0.6.2 (#138488) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 174e8025dd0..5fbcf759ee3 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.1"], + "requirements": ["aiowebostv==0.6.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ba5aeee25df..1bfef744049 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ea50ac1d32..f6080e96729 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 From 099adebcb68b08db715065aad68586c4fb49aa22 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 14 Feb 2025 01:04:39 +0100 Subject: [PATCH 059/155] Bump ZHA to 0.0.49 to fix Tuya TRV issues (#138492) Bump ZHA to 0.0.49 --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 821159afb22..54de60b8669 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.48"], + "requirements": ["zha==0.0.49"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 1bfef744049..551bc833a43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3137,7 +3137,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6080e96729..a9bad901ecb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 6a4f5188b1b4549c7d3a6709b0e0212f70c1e385 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 14 Feb 2025 01:30:53 +0100 Subject: [PATCH 060/155] Bump PyViCare to 2.42.1 (#138494) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 489d4accb8a..96935ba4ba7 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.0"] + "requirements": ["PyViCare==2.42.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 551bc833a43..b4b190acda3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9bad901ecb..ce23da1ec81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 83f8a4454d042d66e0520bffccd5816660039945 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Fri, 14 Feb 2025 09:14:44 +0000 Subject: [PATCH 061/155] squeezebox bump pysqueezebox to 0.12.0 (#138205) * bump pysqueezebox to 0.12.0 * python3 -m script.gen_requirements_all --- homeassistant/components/squeezebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 09eaa4026f4..e9b89291749 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.11.1"] + "requirements": ["pysqueezebox==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4b190acda3..5b87b3c73c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2337,7 +2337,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce23da1ec81..d27c5a29b51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1909,7 +1909,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.suez_water pysuezV2==2.0.3 From 51beb21fe461dabca2f5734667a2c8e7905e3a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 10:19:00 +0100 Subject: [PATCH 062/155] Bump hass-nabucasa from 0.91.0 to 0.92.0 (#138510) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 16d340a480b..4e99d08afb5 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.91.0"], + "requirements": ["hass-nabucasa==0.92.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b49409d9ce7..997a2167654 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index e693b6ec9c5..7b40570015d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.91.0", + "hass-nabucasa==0.92.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 7baea71e608..139f0c168f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5b87b3c73c0..33a78a36da7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d27c5a29b51..d2dc9851b62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.conversation hassil==2.2.3 From d82dd9e7e6792a1397dccdadf1a1c54d5e40ba99 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 14 Feb 2025 11:25:04 +0200 Subject: [PATCH 063/155] Bump pyseventeentrack to 1.0.2 (#138506) Bump pyseventeentrack version --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index a130fbe9aee..34019208a14 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.1"] + "requirements": ["pyseventeentrack==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 33a78a36da7..209fa514a97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2286,7 +2286,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2dc9851b62..1b05c7b2db2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pysensibo==1.1.0 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 From b9148d6368bd8323e77cc5097209f34027a6f8f0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 10:37:56 +0100 Subject: [PATCH 064/155] Improve descriptions of snooz.transition_xx actions (#138403) The current action descriptions of the snooz integration are easy to misunderstand and result in wrong translations. This commit replaces them with the wording from the online docs, slightly adapted for the UI that already displays the units and ranges. --- homeassistant/components/snooz/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 94ca434e589..ca252b2117c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -27,25 +27,25 @@ "services": { "transition_on": { "name": "Transition on", - "description": "Transitions to a target volume level over time.", + "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.", "fields": { "duration": { "name": "Transition duration", - "description": "Time it takes to reach the target volume level." + "description": "Time to transition to the target volume." }, "volume": { "name": "Target volume", - "description": "If not specified, the volume level is read from the device." + "description": "Relative volume level. If not specified, the setting on the device is used." } } }, "transition_off": { "name": "Transition off", - "description": "Transitions volume off over time.", + "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", "fields": { "duration": { "name": "[%key:component::snooz::services::transition_on::fields::duration::name%]", - "description": "Time it takes to turn off." + "description": "Time to complete the transition." } } } From 9f9aeb4cce3fc59ca0cc3c37a3c8c0106033ffc8 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:10:08 +0100 Subject: [PATCH 065/155] Add entity category to non primary entities for motionmount integration (#138436) Add entity category to non primary entities --- homeassistant/components/motionmount/binary_sensor.py | 2 ++ homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index c9d76ebb8d5..d0d6825ee40 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,6 +32,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 2648355c3af..7df450d88f3 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -56,7 +56,7 @@ rules: dynamic-devices: status: exempt comment: Single device per config entry - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: todo entity-translations: done diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 4950e5d6662..9ca8d2b0731 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -6,6 +6,7 @@ import motionmount from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +48,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): "internal", ] _attr_translation_key = "motionmount_error_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry From 4d3a4015edb3edf6d0865fec730ecd48ad34205e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:39:04 +0100 Subject: [PATCH 066/155] =?UTF-8?q?Update=20quality=20scale=20to=20platinu?= =?UTF-8?q?m=20=F0=9F=8F=86=EF=B8=8F=20for=20Bring!=20integration=20(#1382?= =?UTF-8?q?02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update documentation status in bring quality_scale.yaml * Update quality scale * options flow exempt --- homeassistant/components/bring/manifest.json | 1 + .../components/bring/quality_scale.yaml | 28 +++++++++++-------- script/hassfest/quality_scale.py | 1 - 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index b846cb1c5ca..f292b10f7dc 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], + "quality_scale": "platinum", "requirements": ["bring-api==1.0.2"] } diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 58e67ab0e11..2d7d67be12e 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -10,9 +10,9 @@ rules: config-flow: done dependency-transparency: done docs-actions: done - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: The integration registers no events @@ -26,8 +26,10 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: @@ -46,13 +48,15 @@ rules: discovery: status: exempt comment: Integration is a service and has no devices. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Integration is a service and has no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e5eee2f4157..60a5f073538 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1286,7 +1286,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "brottsplatskartan", "browser", "brunt", - "bring", "bryant_evolution", "bsblan", "bt_home_hub_5", From f407dbd35c11f8701947bf6205ee4417ca5720eb Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 12:46:41 +0100 Subject: [PATCH 067/155] Disable less used entities by default in MotionMount integration (#138509) * Mark sensors as disabled by default as most users won't need them * Mark entity-disabled-by-default as done * Enable disabled entities during tests --- homeassistant/components/motionmount/binary_sensor.py | 1 + homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 1 + tests/components/motionmount/test_sensor.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index d0d6825ee40..4bb880311f9 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -33,6 +33,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 7df450d88f3..765cdd7e945 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -58,7 +58,7 @@ rules: comment: Single device per config entry entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: todo diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 9ca8d2b0731..28fe921d9ac 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -49,6 +49,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): ] _attr_translation_key = "motionmount_error_status" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index bb68c67ce62..0320e62d640 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -14,6 +14,7 @@ from tests.common import MockConfigEntry MAC = bytes.fromhex("c4dd57f8a55f") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("system_status", "state"), [ From efd7ddeb89f643241ffe78680bfb00b063de4667 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 13:06:07 +0100 Subject: [PATCH 068/155] Improve tests of removing and unloading config entries (#138432) * Improve tests of removing and unloading config entries * Fix unnecessary coroutine --- tests/test_config_entries.py | 54 +++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index cf022c42e94..bf2280790fa 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -462,7 +462,15 @@ async def test_remove_entry( assert result return result - mock_remove_entry = AsyncMock(return_value=None) + remove_entry_calls = [] + + async def mock_remove_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Mock removing an entry.""" + # Check that the entry is not yet removed from config entries + assert hass.config_entries.async_get_entry(entry.entry_id) + remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") @@ -522,7 +530,7 @@ async def test_remove_entry( assert result == {"require_restart": False} # Check the remove callback was invoked. - assert mock_remove_entry.call_count == 1 + assert len(remove_entry_calls) == 1 # Check that config entry was removed. assert manager.async_entry_ids() == ["test1", "test3"] @@ -2611,29 +2619,49 @@ async def test_entry_setup_invalid_state( assert entry.state is state -async def test_entry_unload_succeed( - hass: HomeAssistant, manager: config_entries.ConfigEntries +@pytest.mark.parametrize( + ("unload_result", "expected_result", "expected_state", "has_runtime_data"), + [ + (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), + (False, False, config_entries.ConfigEntryState.LOADED, True), + ], +) +async def test_entry_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unload_result: bool, + expected_result: bool, + expected_state: config_entries.ConfigEntryState, + has_runtime_data: bool, ) -> None: """Test that we can unload an entry.""" - unloads_called = [] + unload_entry_calls = [] - async def verify_runtime_data(*args): + @callback + def verify_runtime_data() -> None: """Verify runtime data.""" assert entry.runtime_data == 2 - unloads_called.append(args) - return True + + async def async_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unload entry.""" + unload_entry_calls.append(None) + verify_runtime_data() + assert entry.state is config_entries.ConfigEntryState.LOADED + return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) entry.async_on_unload(verify_runtime_data) entry.runtime_data = 2 - mock_integration(hass, MockModule("comp", async_unload_entry=verify_runtime_data)) + mock_integration(hass, MockModule("comp", async_unload_entry=async_unload_entry)) - assert await manager.async_unload(entry.entry_id) - assert len(unloads_called) == 2 - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert not hasattr(entry, "runtime_data") + assert await manager.async_unload(entry.entry_id) == expected_result + assert len(unload_entry_calls) == 1 + assert entry.state is expected_state + assert hasattr(entry, "runtime_data") == has_runtime_data @pytest.mark.parametrize( From fa4ebeb6805ee3d215e03d03b1cbea2ef421faf4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:11:32 +0100 Subject: [PATCH 069/155] Bump py-synologydsm-api to 2.6.3 (#138516) bump py-synologydsm-api to 2.6.3 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index a083fa5a15f..d076d843c36 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.6.2"], + "requirements": ["py-synologydsm-api==2.6.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 209fa514a97..fe2127bcab0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1749,7 +1749,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b05c7b2db2..8b9cc8455e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1447,7 +1447,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From fae68c8ad580b103aea5f2ace77d1cf00a65e417 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:47:36 +0100 Subject: [PATCH 070/155] Add icon translation to MotionMount integration (#138520) * Add icon translation for error sensor * Mark icon-translations as done --- homeassistant/components/motionmount/icons.json | 12 ++++++++++++ .../components/motionmount/quality_scale.yaml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/motionmount/icons.json diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json new file mode 100644 index 00000000000..8d6d867f4d0 --- /dev/null +++ b/homeassistant/components/motionmount/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "motionmount_error_status": { + "default": "mdi:alert-circle-outline", + "state": { + "none": "mdi:check-circle-outline" + } + } + } + } +} diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 765cdd7e945..8b210931eaf 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -61,7 +61,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt From 48f58c7d497be2804a75eab178a5a8d833a50e87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 13:52:22 +0100 Subject: [PATCH 071/155] Fix action descriptions in Xiaomi Miio integration (#138476) * Fix action description in Xiaomi Miio integration Correct several missing descriptions, wrong references to completely different actions, resulting duplicates and copy & paste errors. Make the grammar more consistent across all strings. Make one occurrence of "xiaomi miio" consistent by capitalizing. * Apply suggestions from @CFenner review Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> * Change "on a light" to "of a light", remove wrong comma * Change "turn off" to "turning off" according to OED --------- Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> --- .../components/xiaomi_miio/strings.json | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 75563b07559..bd3b3499689 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the xiaomi miio entity." + "description": "Name of the Xiaomi Miio entity." } } }, @@ -365,7 +365,7 @@ }, "light_set_delayed_turn_off": { "name": "Light set delayed turn off", - "description": "Delayed turn off.", + "description": "Sets the delayed turning off of a light.", "fields": { "entity_id": { "name": "Entity ID", @@ -373,7 +373,7 @@ }, "time_period": { "name": "Time period", - "description": "Time period for the delayed turn off." + "description": "Time period for the delayed turning off." } } }, @@ -398,8 +398,8 @@ } }, "light_night_light_mode_on": { - "name": "Night light mode on", - "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode on", + "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -408,8 +408,8 @@ } }, "light_night_light_mode_off": { - "name": "Night light mode off", - "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode off", + "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -419,7 +419,7 @@ }, "light_eyecare_mode_on": { "name": "Light eyecare mode on", - "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]", + "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -429,7 +429,7 @@ }, "light_eyecare_mode_off": { "name": "Light eyecare mode off", - "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]", + "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", @@ -447,21 +447,21 @@ }, "timeout": { "name": "Timeout", - "description": "Define the timeout, before which the command must be learned." + "description": "Define the timeout before which the command must be learned." } } }, "remote_set_led_on": { "name": "Remote set LED on", - "description": "Turns on blue LED." + "description": "Turns on the remote’s blue LED." }, "remote_set_led_off": { "name": "Remote set LED off", - "description": "Turns off blue LED." + "description": "Turns off the remote’s blue LED." }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the Wi-Fi LED on.", + "description": "Turns on the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turns the Wi-Fi LED off.", + "description": "Turns off the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", From 371490a4705e1e54e012d8372acf243f69e9f19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 13:57:27 +0100 Subject: [PATCH 072/155] Add sensor platform to LetPot integration (#138491) * Add sensor platform to LetPot integration * Handle support in description supported_fn, use common string * Update homeassistant/components/letpot/switch.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/strings.json * Fix translation key in snapshot * snapshot no quotes --------- Co-authored-by: Josef Zweck --- homeassistant/components/letpot/__init__.py | 2 +- homeassistant/components/letpot/entity.py | 9 ++ homeassistant/components/letpot/icons.json | 5 + .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/sensor.py | 110 ++++++++++++++++++ homeassistant/components/letpot/strings.json | 5 + homeassistant/components/letpot/switch.py | 52 ++++----- .../letpot/snapshots/test_sensor.ambr | 104 +++++++++++++++++ tests/components/letpot/test_sensor.py | 28 +++++ 9 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/letpot/sensor.py create mode 100644 tests/components/letpot/snapshots/test_sensor.ambr create mode 100644 tests/components/letpot/test_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index bc84c22d4a2..dc322d5641b 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,7 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index b4d505f4092..5e2c46fee84 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,18 +1,27 @@ """Base class for LetPot entities.""" from collections.abc import Callable, Coroutine +from dataclasses import dataclass from typing import Any, Concatenate from letpot.exceptions import LetPotConnectionException, LetPotException from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LetPotDeviceCoordinator +@dataclass(frozen=True, kw_only=True) +class LetPotEntityDescription(EntityDescription): + """Description for all LetPot entities.""" + + supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True + + class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): """Defines a base LetPot entity.""" diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 2a2b727adcd..60cba78fa1c 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,10 @@ { "entity": { + "sensor": { + "water_level": { + "default": "mdi:water-percent" + } + }, "switch": { "alarm_sound": { "default": "mdi:bell-ring", diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 70f3bb52b82..0fdaca18717 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -59,8 +59,8 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo + entity-category: done + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py new file mode 100644 index 00000000000..b0b113eb063 --- /dev/null +++ b/homeassistant/components/letpot/sensor.py @@ -0,0 +1,110 @@ +"""Support for LetPot sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +LETPOT_TEMPERATURE_UNIT_HA_UNIT = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, +} + + +@dataclass(frozen=True, kw_only=True) +class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription): + """Describes a LetPot sensor entity.""" + + native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None] + value_fn: Callable[[LetPotDeviceStatus], StateType] + + +SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( + LetPotSensorEntityDescription( + key="temperature", + value_fn=lambda status: status.temperature_value, + native_unit_of_measurement_fn=( + lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[ + status.temperature_unit or TemperatureUnit.CELSIUS + ] + ), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.TEMPERATURE + in coordinator.device_client.device_features + ), + ), + LetPotSensorEntityDescription( + key="water_level", + translation_key="water_level", + value_fn=lambda status: status.water_level, + native_unit_of_measurement_fn=lambda _: PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.WATER_LEVEL + in coordinator.device_client.device_features + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot sensor entities based on a device features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotSensorEntity(coordinator, description) + for description in SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotSensorEntity(LetPotEntity, SensorEntity): + """Defines a LetPot sensor entity.""" + + entity_description: LetPotSensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSensorEntityDescription, + ) -> None: + """Initialize LetPot sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + return self.entity_description.native_unit_of_measurement_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 12913085644..0cb79ce711c 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "sensor": { + "water_level": { + "name": "Water level" + } + }, "switch": { "alarm_sound": { "name": "Alarm sound" diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 41150d1b1e9..0b00318c53b 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -from .entity import LetPotEntity, exception_handler +from .entity import LetPotEntity, LetPotEntityDescription, exception_handler # Each change pushes a 'full' device status with the change. The library will cache # pending changes to avoid overwriting, but try to avoid a lot of parallelism. @@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotSwitchEntityDescription(SwitchEntityDescription): +class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription): """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] -BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( +SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( + LetPotSwitchEntityDescription( + key="alarm_sound", + translation_key="alarm_sound", + value_fn=lambda status: status.system_sound, + set_value_fn=lambda device_client, value: device_client.set_sound(value), + entity_category=EntityCategory.CONFIG, + supported_fn=lambda coordinator: coordinator.data.system_sound is not None, + ), + LetPotSwitchEntityDescription( + key="auto_mode", + translation_key="auto_mode", + value_fn=lambda status: status.water_mode == 1, + set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + entity_category=EntityCategory.CONFIG, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_AUTO + in coordinator.device_client.device_features + ), + ), LetPotSwitchEntityDescription( key="power", translation_key="power", @@ -44,20 +63,6 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ), ) -ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="alarm_sound", - translation_key="alarm_sound", - value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), - entity_category=EntityCategory.CONFIG, -) -AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="auto_mode", - translation_key="auto_mode", - value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), - entity_category=EntityCategory.CONFIG, -) async def async_setup_entry( @@ -69,19 +74,10 @@ async def async_setup_entry( coordinators = entry.runtime_data entities: list[SwitchEntity] = [ LetPotSwitchEntity(coordinator, description) - for description in BASE_SWITCHES + for description in SWITCHES for coordinator in coordinators + if description.supported_fn(coordinator) ] - entities.extend( - LetPotSwitchEntity(coordinator, ALARM_SWITCH) - for coordinator in coordinators - if coordinator.data.system_sound is not None - ) - entities.extend( - LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH) - for coordinator in coordinators - if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features - ) async_add_entities(entities) diff --git a/tests/components/letpot/snapshots/test_sensor.ambr b/tests/components/letpot/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5d123cf6ce0 --- /dev/null +++ b/tests/components/letpot/snapshots/test_sensor.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_water_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py new file mode 100644 index 00000000000..a527d062ca7 --- /dev/null +++ b/tests/components/letpot/test_sensor.py @@ -0,0 +1,28 @@ +"""Test sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7dd678ccdf13d7074319aec2dc556b43e7719214 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 14 Feb 2025 14:12:49 +0100 Subject: [PATCH 073/155] Update frontend to 20250214.0 (#138521) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 912ce508e00..c8506335e16 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250210.0"] + "requirements": ["home-assistant-frontend==20250214.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 997a2167654..ed1a1f68621 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe2127bcab0..b02763bd82b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b9cc8455e7..cdd252d7091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 23d43b23ee0bdb9dfe41eee87ca3cff0577249ad Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Fri, 14 Feb 2025 08:03:47 -0700 Subject: [PATCH 074/155] Bump arcam-fmj to 1.8.0 (#138422) * arcam_fmj: bump arcam-fmj to 1.8.0 * Revert castings --------- Co-authored-by: Franck Nijhof --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 39d289f9cb1..944c70c1217 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.5.2"], + "requirements": ["arcam-fmj==1.8.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index b02763bd82b..43f850d14ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -497,7 +497,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdd252d7091..f2877dfacfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -467,7 +467,7 @@ apsystems-ez1==2.4.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 7bd2c1d710712e5b79877c9e9dae24a6e3c437bd Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:20:19 +0100 Subject: [PATCH 075/155] Refactor and add tests to image platform of Habitica (#135897) --- homeassistant/components/habitica/image.py | 14 ++- .../test_image/test_image_platform.1.png | Bin 0 -> 70 bytes .../test_image/test_image_platform.png | Bin 0 -> 70 bytes tests/components/habitica/test_image.py | 99 ++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.png create mode 100644 tests/components/habitica/test_image.py diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f1ade2cac44..1669f124bc7 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -43,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): translation_key=HabiticaImageEntity.AVATAR, ) _attr_content_type = "image/png" - _current_appearance: Avatar | None = None + _avatar: Avatar | None = None _cache: bytes | None = None def __init__( @@ -55,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity): super().__init__(coordinator, self.entity_description) ImageEntity.__init__(self, hass) self._attr_image_last_updated = dt_util.utcnow() + self._avatar = extract_avatar(self.coordinator.data.user) def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - new_appearance = extract_avatar(self.coordinator.data.user) - if self._current_appearance != new_appearance: - self._current_appearance = new_appearance + if self._avatar != self.coordinator.data.user: + self._avatar = extract_avatar(self.coordinator.data.user) self._attr_image_last_updated = dt_util.utcnow() self._cache = None @@ -69,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity): async def async_image(self) -> bytes | None: """Return cached bytes, otherwise generate new avatar.""" - if not self._cache and self._current_appearance: - self._cache = await self.coordinator.generate_avatar( - self._current_appearance - ) + if not self._cache and self._avatar: + self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png new file mode 100644 index 0000000000000000000000000000000000000000..5bb8c9d9f091c7a448a220a122933a61ded065d1 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{P-`=z|79XV4!w9 Q5h%gn>FVdQ&MBb@0IqossQ>@~ literal 0 HcmV?d00001 diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png new file mode 100644 index 0000000000000000000000000000000000000000..8e9b046ee05dbf00e565c46dda27eb844c562b4e GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYf8c{W11l>NL;Q)4 Qmw*xsp00i_>zopr0M?)o)c^nh literal 0 HcmV?d00001 diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py new file mode 100644 index 00000000000..17089f57bd7 --- /dev/null +++ b/tests/components/habitica/test_image.py @@ -0,0 +1,99 @@ +"""Tests for the Habitica image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from io import BytesIO +import sys +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.image import PNGImageSnapshotExtension + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@pytest.mark.skipif( + sys.platform != "linux", reason="linux only" +) # Pillow output on win/mac is different +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test image platform.""" + freezer.move_to("2024-09-20T22:00:00.000") + with patch( + "homeassistant.components.habitica.coordinator.BytesIO", + ) as avatar: + avatar.side_effect = [ + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" + ), + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" + ), + ] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.test_user_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("rogue_fixture.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:01:00+00:00" + + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) From 28ea55aac0ebfa63859e4dea1e3b3ee9328e30d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 09:27:16 -0600 Subject: [PATCH 076/155] Bump aiohttp-asyncmdnsresolver to 0.1.1 (#138534) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ed1a1f68621..b7592bf0f05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.12 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 7b40570015d..553ced3da43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", - "aiohttp-asyncmdnsresolver==0.1.0", + "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.1", diff --git a/requirements.txt b/requirements.txt index 139f0c168f1..2b7290fa042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.3.0 aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.1 From 5dc1689e7c5157f03665563b87e89624eaf0642f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 18:06:17 +0100 Subject: [PATCH 077/155] Update action descriptions of weather integration (#138540) --- homeassistant/components/weather/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 85d331f5bd0..31e644b32e3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -90,17 +90,17 @@ "services": { "get_forecasts": { "name": "Get forecasts", - "description": "Get weather forecasts.", + "description": "Retrieves the forecast from selected weather services.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "description": "The scope of the weather forecast." } } }, "get_forecast": { "name": "Get forecast", - "description": "Get weather forecast.", + "description": "Retrieves the forecast from a selected weather service.", "fields": { "type": { "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", @@ -111,12 +111,12 @@ }, "issues": { "deprecated_service_weather_get_forecast": { - "title": "Detected use of deprecated service weather.get_forecast", + "title": "Detected use of deprecated action weather.get_forecast", "fix_flow": { "step": { "confirm": { "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", - "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue." + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue." } } } From 11aa08cf74c722ed310705bac70140b77b7f50e2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:56:32 +0100 Subject: [PATCH 078/155] =?UTF-8?q?Set=20quality=20scale=20to=20platinum?= =?UTF-8?q?=20=F0=9F=8F=86=EF=B8=8F=20for=20Habitica=20integration=20(#136?= =?UTF-8?q?076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/habitica/manifest.json | 1 + homeassistant/components/habitica/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a58bd1296e0..48b6997239e 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], + "quality_scale": "platinum", "requirements": ["habiticalib==0.3.7"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 9eadba496f2..1752e67cf46 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: exempt comment: No supportable devices. docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 60a5f073538..12b5932695d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1535,7 +1535,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "gstreamer", "gtfs", "guardian", - "habitica", "harman_kardon_avr", "harmony", "hassio", From d99044572a1ea6a047975bf91b5c29002cb1963d Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:03:21 -0500 Subject: [PATCH 079/155] Improved auth failure handling in Nice G.O. (#136607) --- .../components/nice_go/coordinator.py | 4 +- homeassistant/components/nice_go/cover.py | 26 ++---- homeassistant/components/nice_go/light.py | 26 ++---- homeassistant/components/nice_go/switch.py | 26 ++---- homeassistant/components/nice_go/util.py | 66 ++++++++++++++ tests/components/nice_go/test_cover.py | 85 ++++++++++++++++++- tests/components/nice_go/test_light.py | 85 ++++++++++++++++++- tests/components/nice_go/test_switch.py | 85 ++++++++++++++++++- 8 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/nice_go/util.py diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index e486263fbe5..ffdd9dbd518 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ) try: if datetime.now().timestamp() >= expiry_time: - await self._update_refresh_token() + await self.update_refresh_token() else: await self.api.authenticate_refresh( self.refresh_token, async_get_clientsession(self.hass) @@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): else: self.async_set_updated_data(devices) - async def _update_refresh_token(self) -> None: + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") try: diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 03124971410..b9b39711a01 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -2,21 +2,17 @@ from typing import Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry DEVICE_CLASSES = { "WallStation": CoverDeviceClass.GARAGE, @@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity): """Return if cover is closing.""" return self.data.barrier_status == "closing" + @retry("close_cover_error") async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" if self.is_closed: return - try: - await self.coordinator.api.close_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="close_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.close_barrier(self._device_id) + @retry("open_cover_error") async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" if self.is_opened: return - try: - await self.coordinator.api.open_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="open_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.open_barrier(self._device_id) diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index 5b06c02f5db..bf283ed6eff 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -3,23 +3,19 @@ import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity): assert self.data.light_status is not None return self.data.light_status + @retry("light_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - try: - await self.coordinator.api.light_on(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_on(self._device_id) + @retry("light_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.api.light_off(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_off(self._device_id) diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index e81ea489d2f..f043a23eab5 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -5,23 +5,19 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity): assert self.data.vacation_mode is not None return self.data.vacation_mode + @retry("switch_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - try: - await self.coordinator.api.vacation_mode_on(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_on(self.data.id) + @retry("switch_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - try: - await self.coordinator.api.vacation_mode_off(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_off(self.data.id) diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py new file mode 100644 index 00000000000..02dee6b0ac1 --- /dev/null +++ b/homeassistant/components/nice_go/util.py @@ -0,0 +1,66 @@ +"""Utilities for Nice G.O.""" + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, Protocol, runtime_checkable + +from aiohttp import ClientError +from nice_go import ApiError, AuthFailedError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DOMAIN + + +@runtime_checkable +class _ArgsProtocol(Protocol): + coordinator: Any + hass: Any + + +def retry[_R, **P]( + translation_key: str, +) -> Callable[ + [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]] +]: + """Retry decorator to handle API errors.""" + + def decorator( + func: Callable[P, Coroutine[Any, Any, _R]], + ) -> Callable[P, Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs): + instance = args[0] + if not isinstance(instance, _ArgsProtocol): + raise TypeError("First argument must have correct attributes") + try: + return await func(*args, **kwargs) + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except AuthFailedError: + # Try refreshing token and retry + try: + await instance.coordinator.update_refresh_token() + return await func(*args, **kwargs) + except (ApiError, ClientError, UpdateFailed) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except (AuthFailedError, ConfigEntryAuthFailed) as err: + instance.coordinator.config_entry.async_start_reauth(instance.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + + return wrapper + + return decorator diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index f90c2d438b0..542b1717d88 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -154,3 +154,86 @@ async def test_cover_exceptions( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + def _open_side_effect(*args, **kwargs): + if mock_nice_go.open_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 5: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 6: + raise ApiError + + def _close_side_effect(*args, **kwargs): + if mock_nice_go.close_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.close_barrier.call_count == 4: + raise ApiError + + mock_nice_go.open_barrier.side_effect = _open_side_effect + mock_nice_go.close_barrier.side_effect = _close_side_effect + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.open_barrier.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.close_barrier.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.open_barrier.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.open_barrier.call_count == 6 + assert mock_nice_go.close_barrier.call_count == 4 diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index b170a0ee3ab..2bc9de59b2b 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -160,3 +160,86 @@ async def test_unsupported_device_type( "Please create an issue with your device model in additional info" in caplog.text ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.light_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.light_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_off.call_count == 4: + raise ApiError + + mock_nice_go.light_on.side_effect = _on_side_effect + mock_nice_go.light_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.light_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.light_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.light_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.light_on.call_count == 6 + assert mock_nice_go.light_off.call_count == 4 diff --git a/tests/components/nice_go/test_switch.py b/tests/components/nice_go/test_switch.py index d3a2141eb2b..cab009c5b94 100644 --- a/tests/components/nice_go/test_switch.py +++ b/tests/components/nice_go/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from homeassistant.components.switch import ( @@ -88,3 +88,86 @@ async def test_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_off.call_count == 4: + raise ApiError + + mock_nice_go.vacation_mode_on.side_effect = _on_side_effect + mock_nice_go.vacation_mode_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.vacation_mode_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.vacation_mode_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.vacation_mode_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.vacation_mode_on.call_count == 6 + assert mock_nice_go.vacation_mode_off.call_count == 4 From 2bfe96dded803ecd1ed0f65cba729eb2adf40dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Feb 2025 20:21:01 +0100 Subject: [PATCH 080/155] Add Home Connect action with recognized programs and options (#130662) * Added recognized options to Home Connect actions * Fix ruff * Fix strings.json * Fix dishwasher typo * Improved test_bsh_key_transformations * Add missing return types * Added descriptions * Remove custom options * Fixes * Merge the 4 services (select, start, set options for active or selected program) And deprecate the original ones * Delete stale snapshots * Clean up logic after service validation * Make deprecated actions issues fixable And delete issue on entry unload * Fixes and improvements Co-authored-by: Martin Hjelmare * Improvements Co-authored-by: Martin Hjelmare * Fix name and descriptions * Add `affects_to` to strings and service.yaml * Add missing periods at strings * Fix Co-authored-by: Norbert Rittel * Add tests to check if the flow removes the deprecated action issue --------- Co-authored-by: Martin Hjelmare Co-authored-by: Norbert Rittel --- .../components/home_connect/__init__.py | 284 +++- .../components/home_connect/const.py | 249 +++- .../components/home_connect/icons.json | 3 + .../components/home_connect/manifest.json | 2 +- .../components/home_connect/select.py | 20 +- .../components/home_connect/services.yaml | 526 ++++++++ .../components/home_connect/strings.json | 1174 ++++++++++++----- tests/components/home_connect/conftest.py | 68 +- .../home_connect/snapshots/test_init.ambr | 79 ++ tests/components/home_connect/test_init.py | 281 +++- 10 files changed, 2331 insertions(+), 355 deletions(-) create mode 100644 tests/components/home_connect/snapshots/test_init.ambr diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index becc78cef90..59a33f01bcb 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,11 +2,20 @@ from __future__ import annotations +from collections.abc import Awaitable +from datetime import timedelta import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol @@ -19,34 +28,84 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, + PROGRAM_ENUM_OPTIONS, SERVICE_OPTION_ACTIVE, SERVICE_OPTION_SELECTED, SERVICE_PAUSE_PROGRAM, SERVICE_RESUME_PROGRAM, SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, SERVICE_START_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, + TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + +TIME_PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, + OptionKey.BSH_COMMON_DURATION: cv.time_period_str, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, + }.items() +} + + SERVICE_SETTING_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -58,6 +117,7 @@ SERVICE_SETTING_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_OPTION_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -70,6 +130,7 @@ SERVICE_OPTION_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_PROGRAM_SCHEMA = vol.Any( { vol.Required(ATTR_DEVICE_ID): str, @@ -93,6 +154,51 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( }, ) + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data + for option_key in ( + PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in ( + PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ).items() + } + ), + _require_program_or_at_least_one_option, +) + SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ @@ -144,7 +250,7 @@ async def _get_client_and_ha_id( return entry.runtime_data.client, ha_id -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up Home Connect component.""" async def _async_service_program(call: ServiceCall, start: bool): @@ -165,6 +271,57 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else None ) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: if start: await client.start_program(ha_id, program_key=program, options=options) @@ -189,6 +346,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) try: if active: await client.set_active_program_option( @@ -272,6 +467,82 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Service for selecting a program.""" await _async_service_program(call, False) + async def async_service_set_program_and_options(call: ServiceCall): + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + elif option in TIME_PROGRAM_OPTIONS: + options.append( + Option( + TIME_PROGRAM_OPTIONS[option][0], + int(cast(timedelta, value).total_seconds()), + ) + ) + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **( + {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} + if program + else {} + ), + }, + ) from err + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" await _async_service_program(call, True) @@ -315,6 +586,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_service_start_program, schema=SERVICE_PROGRAM_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) return True @@ -349,6 +626,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" + async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 127aa1ffe92..0ec7d3a2629 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,6 +1,10 @@ """Constants for the Home Connect integration.""" -from aiohomeconnect.model import EventKey, SettingKey, StatusKey +from typing import cast + +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey + +from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -52,15 +56,18 @@ SERVICE_OPTION_SELECTED = "set_option_selected" SERVICE_PAUSE_PROGRAM = "pause_program" SERVICE_RESUME_PROGRAM = "resume_program" SERVICE_SELECT_PROGRAM = "select_program" +SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" - +ATTR_AFFECTS_TO = "affects_to" ATTR_KEY = "key" ATTR_PROGRAM = "program" ATTR_UNIT = "unit" ATTR_VALUE = "value" +AFFECTS_TO_ACTIVE_PROGRAM = "active_program" +AFFECTS_TO_SELECTED_PROGRAM = "selected_program" SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" @@ -70,6 +77,244 @@ SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" +TRANSLATION_KEYS_PROGRAMS_MAP = { + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN +} + +PROGRAMS_TRANSLATION_KEYS_MAP = { + value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() +} + +REFERENCE_MAP_ID_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3", + ) +} + +CLEANING_MODE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power", + ) +} + +BEAN_AMOUNT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround", + ) +} + +COFFEE_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C", + ) +} + +BEAN_CONTAINER_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right", + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left", + ) +} + +FLOW_RATE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus", + ) +} + +HOT_WATER_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max", + ) +} + +DRYING_TARGET_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Dryer.EnumType.DryingTarget.IronDry", + "LaundryCare.Dryer.EnumType.DryingTarget.GentleDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus", + "LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry", + ) +} + +VENTING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.Stage.FanOff", + "Cooking.Hood.EnumType.Stage.FanStage01", + "Cooking.Hood.EnumType.Stage.FanStage02", + "Cooking.Hood.EnumType.Stage.FanStage03", + "Cooking.Hood.EnumType.Stage.FanStage04", + "Cooking.Hood.EnumType.Stage.FanStage05", + ) +} + +INTENSIVE_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2", + ) +} + +WARMING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Oven.EnumType.WarmingLevel.Low", + "Cooking.Oven.EnumType.WarmingLevel.Medium", + "Cooking.Oven.EnumType.WarmingLevel.High", + ) +} + +TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.Temperature.Cold", + "LaundryCare.Washer.EnumType.Temperature.GC20", + "LaundryCare.Washer.EnumType.Temperature.GC30", + "LaundryCare.Washer.EnumType.Temperature.GC40", + "LaundryCare.Washer.EnumType.Temperature.GC50", + "LaundryCare.Washer.EnumType.Temperature.GC60", + "LaundryCare.Washer.EnumType.Temperature.GC70", + "LaundryCare.Washer.EnumType.Temperature.GC80", + "LaundryCare.Washer.EnumType.Temperature.GC90", + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ) +} + +SPIN_SPEED_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.SpinSpeed.Off", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1600", + "LaundryCare.Washer.EnumType.SpinSpeed.UlOff", + "LaundryCare.Washer.EnumType.SpinSpeed.UlLow", + "LaundryCare.Washer.EnumType.SpinSpeed.UlMedium", + "LaundryCare.Washer.EnumType.SpinSpeed.UlHigh", + ) +} + +VARIO_PERFECT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Common.EnumType.VarioPerfect.Off", + "LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect", + "LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect", + ) +} + + +PROGRAM_ENUM_OPTIONS = { + bsh_key_to_translation_key(option_key): ( + option_key, + options, + ) + for option_key, options in ( + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + REFERENCE_MAP_ID_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + CLEANING_MODE_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + COFFEE_TEMPERATURE_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + BEAN_CONTAINER_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + HOT_WATER_TEMPERATURE_OPTIONS, + ), + (OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS), + (OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS), + (OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS), + ) +} + + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 166b2fe2c34..6b604fc004e 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -18,6 +18,9 @@ "set_option_selected": { "service": "mdi:gesture-tap" }, + "set_program_and_options": { + "service": "mdi:form-select" + }, "change_setting": { "service": "mdi:cog" } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 94085af2fc3..06325afaed8 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -3,7 +3,7 @@ "name": "Home Connect", "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "repairs"], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 165842abf1c..bc281e3d928 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -15,24 +15,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .const import ( + APPLIANCES_WITH_PROGRAMS, + DOMAIN, + PROGRAMS_TRANSLATION_KEYS_MAP, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) from .entity import HomeConnectEntity -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error - -TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program.value): cast(ProgramKey, program) - for program in ProgramKey - if program != ProgramKey.UNKNOWN -} - -PROGRAMS_TRANSLATION_KEYS_MAP = { - value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() -} +from .utils import get_dict_from_home_connect_error @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 0738b58595a..29ca3da15fc 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -46,6 +46,532 @@ select_program: example: "seconds" selector: text: +set_program_and_options: + fields: + device_id: + required: true + selector: + device: + integration: home_connect + affects_to: + example: active_program + required: true + selector: + select: + translation_key: affects_to + options: + - active_program + - selected_program + program: + example: dishcare_dishwasher_program_auto2 + required: true + selector: + select: + mode: dropdown + custom_value: false + translation_key: programs + options: + - consumer_products_cleaning_robot_program_cleaning_clean_all + - consumer_products_cleaning_robot_program_cleaning_clean_map + - consumer_products_cleaning_robot_program_basic_go_home + - consumer_products_coffee_maker_program_beverage_ristretto + - consumer_products_coffee_maker_program_beverage_espresso + - consumer_products_coffee_maker_program_beverage_espresso_doppio + - consumer_products_coffee_maker_program_beverage_coffee + - consumer_products_coffee_maker_program_beverage_x_l_coffee + - consumer_products_coffee_maker_program_beverage_caffe_grande + - consumer_products_coffee_maker_program_beverage_espresso_macchiato + - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_latte_macchiato + - consumer_products_coffee_maker_program_beverage_caffe_latte + - consumer_products_coffee_maker_program_beverage_milk_froth + - consumer_products_coffee_maker_program_beverage_warm_milk + - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_coffee_world_grosser_brauner + - consumer_products_coffee_maker_program_coffee_world_verlaengerter + - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun + - consumer_products_coffee_maker_program_coffee_world_wiener_melange + - consumer_products_coffee_maker_program_coffee_world_flat_white + - consumer_products_coffee_maker_program_coffee_world_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait + - consumer_products_coffee_maker_program_coffee_world_doppio + - consumer_products_coffee_maker_program_coffee_world_kaapi + - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd + - consumer_products_coffee_maker_program_coffee_world_galao + - consumer_products_coffee_maker_program_coffee_world_garoto + - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_red_eye + - consumer_products_coffee_maker_program_coffee_world_black_eye + - consumer_products_coffee_maker_program_coffee_world_dead_eye + - consumer_products_coffee_maker_program_beverage_hot_water + - dishcare_dishwasher_program_pre_rinse + - dishcare_dishwasher_program_auto_1 + - dishcare_dishwasher_program_auto_2 + - dishcare_dishwasher_program_auto_3 + - dishcare_dishwasher_program_eco_50 + - dishcare_dishwasher_program_quick_45 + - dishcare_dishwasher_program_intensiv_70 + - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_glas_40 + - dishcare_dishwasher_program_glass_care + - dishcare_dishwasher_program_night_wash + - dishcare_dishwasher_program_quick_65 + - dishcare_dishwasher_program_normal_45 + - dishcare_dishwasher_program_intensiv_45 + - dishcare_dishwasher_program_auto_half_load + - dishcare_dishwasher_program_intensiv_power + - dishcare_dishwasher_program_magic_daily + - dishcare_dishwasher_program_super_60 + - dishcare_dishwasher_program_kurz_60 + - dishcare_dishwasher_program_express_sparkle_65 + - dishcare_dishwasher_program_machine_care + - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_maximum_cleaning + - dishcare_dishwasher_program_mixed_load + - laundry_care_dryer_program_cotton + - laundry_care_dryer_program_synthetic + - laundry_care_dryer_program_mix + - laundry_care_dryer_program_blankets + - laundry_care_dryer_program_business_shirts + - laundry_care_dryer_program_down_feathers + - laundry_care_dryer_program_hygiene + - laundry_care_dryer_program_jeans + - laundry_care_dryer_program_outdoor + - laundry_care_dryer_program_synthetic_refresh + - laundry_care_dryer_program_towels + - laundry_care_dryer_program_delicates + - laundry_care_dryer_program_super_40 + - laundry_care_dryer_program_shirts_15 + - laundry_care_dryer_program_pillow + - laundry_care_dryer_program_anti_shrink + - laundry_care_dryer_program_my_time_my_drying_time + - laundry_care_dryer_program_time_cold + - laundry_care_dryer_program_time_warm + - laundry_care_dryer_program_in_basket + - laundry_care_dryer_program_time_cold_fix_time_cold_20 + - laundry_care_dryer_program_time_cold_fix_time_cold_30 + - laundry_care_dryer_program_time_cold_fix_time_cold_60 + - laundry_care_dryer_program_time_warm_fix_time_warm_30 + - laundry_care_dryer_program_time_warm_fix_time_warm_40 + - laundry_care_dryer_program_time_warm_fix_time_warm_60 + - laundry_care_dryer_program_dessous + - cooking_common_program_hood_automatic + - cooking_common_program_hood_venting + - cooking_common_program_hood_delayed_shut_off + - cooking_oven_program_heating_mode_pre_heating + - cooking_oven_program_heating_mode_hot_air + - cooking_oven_program_heating_mode_hot_air_eco + - cooking_oven_program_heating_mode_hot_air_grilling + - cooking_oven_program_heating_mode_top_bottom_heating + - cooking_oven_program_heating_mode_top_bottom_heating_eco + - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_pizza_setting + - cooking_oven_program_heating_mode_slow_cook + - cooking_oven_program_heating_mode_intensive_heat + - cooking_oven_program_heating_mode_keep_warm + - cooking_oven_program_heating_mode_preheat_ovenware + - cooking_oven_program_heating_mode_frozen_heatup_special + - cooking_oven_program_heating_mode_desiccation + - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_proof + - cooking_oven_program_heating_mode_hot_air_30_steam + - cooking_oven_program_heating_mode_hot_air_60_steam + - cooking_oven_program_heating_mode_hot_air_80_steam + - cooking_oven_program_heating_mode_hot_air_100_steam + - cooking_oven_program_heating_mode_sabbath_programme + - cooking_oven_program_microwave_90_watt + - cooking_oven_program_microwave_180_watt + - cooking_oven_program_microwave_360_watt + - cooking_oven_program_microwave_600_watt + - cooking_oven_program_microwave_900_watt + - cooking_oven_program_microwave_1000_watt + - cooking_oven_program_microwave_max + - cooking_oven_program_heating_mode_warming_drawer + - laundry_care_washer_program_cotton + - laundry_care_washer_program_cotton_cotton_eco + - laundry_care_washer_program_cotton_eco_4060 + - laundry_care_washer_program_cotton_colour + - laundry_care_washer_program_easy_care + - laundry_care_washer_program_mix + - laundry_care_washer_program_mix_night_wash + - laundry_care_washer_program_delicates_silk + - laundry_care_washer_program_wool + - laundry_care_washer_program_sensitive + - laundry_care_washer_program_auto_30 + - laundry_care_washer_program_auto_40 + - laundry_care_washer_program_auto_60 + - laundry_care_washer_program_chiffon + - laundry_care_washer_program_curtains + - laundry_care_washer_program_dark_wash + - laundry_care_washer_program_dessous + - laundry_care_washer_program_monsoon + - laundry_care_washer_program_outdoor + - laundry_care_washer_program_plush_toy + - laundry_care_washer_program_shirts_blouses + - laundry_care_washer_program_sport_fitness + - laundry_care_washer_program_towels + - laundry_care_washer_program_water_proof + - laundry_care_washer_program_power_speed_59 + - laundry_care_washer_program_super_153045_super_15 + - laundry_care_washer_program_super_153045_super_1530 + - laundry_care_washer_program_down_duvet_duvet + - laundry_care_washer_program_rinse_rinse_spin_drain + - laundry_care_washer_program_drum_clean + - laundry_care_washer_dryer_program_cotton + - laundry_care_washer_dryer_program_cotton_eco_4060 + - laundry_care_washer_dryer_program_mix + - laundry_care_washer_dryer_program_easy_care + - laundry_care_washer_dryer_program_wash_and_dry_60 + - laundry_care_washer_dryer_program_wash_and_dry_90 + cleaning_robot_options: + collapsed: true + fields: + consumer_products_cleaning_robot_option_reference_map_id: + example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + required: false + selector: + select: + mode: dropdown + translation_key: available_maps + options: + - consumer_products_cleaning_robot_enum_type_available_maps_temp_map + - consumer_products_cleaning_robot_enum_type_available_maps_map1 + - consumer_products_cleaning_robot_enum_type_available_maps_map2 + - consumer_products_cleaning_robot_enum_type_available_maps_map3 + consumer_products_cleaning_robot_option_cleaning_mode: + example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + required: false + selector: + select: + mode: dropdown + translation_key: cleaning_mode + options: + - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent + - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + - consumer_products_cleaning_robot_enum_type_cleaning_modes_power + coffee_maker_options: + collapsed: true + fields: + consumer_products_coffee_maker_option_bean_amount: + example: consumer_products_coffee_maker_enum_type_bean_amount_normal + required: false + selector: + select: + mode: dropdown + translation_key: bean_amount + options: + - consumer_products_coffee_maker_enum_type_bean_amount_very_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild_plus + - consumer_products_coffee_maker_enum_type_bean_amount_normal + - consumer_products_coffee_maker_enum_type_bean_amount_normal_plus + - consumer_products_coffee_maker_enum_type_bean_amount_strong + - consumer_products_coffee_maker_enum_type_bean_amount_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_extra_strong + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground + consumer_products_coffee_maker_option_fill_quantity: + example: 60 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: ml + consumer_products_coffee_maker_option_coffee_temperature: + example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + required: false + selector: + select: + mode: dropdown + translation_key: coffee_temperature + options: + - consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_90_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_92_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c + consumer_products_coffee_maker_option_bean_container: + example: consumer_products_coffee_maker_enum_type_bean_container_selection_right + required: false + selector: + select: + mode: dropdown + translation_key: bean_container + options: + - consumer_products_coffee_maker_enum_type_bean_container_selection_right + - consumer_products_coffee_maker_enum_type_bean_container_selection_left + consumer_products_coffee_maker_option_flow_rate: + example: consumer_products_coffee_maker_enum_type_flow_rate_normal + required: false + selector: + select: + mode: dropdown + translation_key: flow_rate + options: + - consumer_products_coffee_maker_enum_type_flow_rate_normal + - consumer_products_coffee_maker_enum_type_flow_rate_intense + - consumer_products_coffee_maker_enum_type_flow_rate_intense_plus + consumer_products_coffee_maker_option_multiple_beverages: + example: false + required: false + selector: + boolean: + consumer_products_coffee_maker_option_coffee_milk_ratio: + example: 50 + required: false + selector: + number: + unit_of_measurement: "%" + step: 10 + min: 10 + max: 90 + consumer_products_coffee_maker_option_hot_water_temperature: + example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + required: false + selector: + select: + mode: dropdown + translation_key: hot_water_temperature + options: + - consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_max + dish_washer_options: + collapsed: true + fields: + b_s_h_common_option_start_in_relative: + example: "30:00" + required: false + selector: + time: + dishcare_dishwasher_option_intensiv_zone: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_brilliance_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_vario_speed_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_silence_on_demand: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_half_load: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_extra_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_hygiene_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_eco_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_zeolite_dry: + example: false + required: false + selector: + boolean: + dryer_options: + collapsed: true + fields: + laundry_care_dryer_option_drying_target: + example: laundry_care_dryer_enum_type_drying_target_iron_dry + required: false + selector: + select: + mode: dropdown + translation_key: drying_target + options: + - laundry_care_dryer_enum_type_drying_target_iron_dry + - laundry_care_dryer_enum_type_drying_target_gentle_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus + - laundry_care_dryer_enum_type_drying_target_extra_dry + hood_options: + collapsed: true + fields: + cooking_hood_option_venting_level: + example: cooking_hood_enum_type_stage_fan_stage01 + required: false + selector: + select: + mode: dropdown + translation_key: venting_level + options: + - cooking_hood_enum_type_stage_fan_off + - cooking_hood_enum_type_stage_fan_stage01 + - cooking_hood_enum_type_stage_fan_stage02 + - cooking_hood_enum_type_stage_fan_stage03 + - cooking_hood_enum_type_stage_fan_stage04 + - cooking_hood_enum_type_stage_fan_stage05 + cooking_hood_option_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + required: false + selector: + select: + mode: dropdown + translation_key: intensive_level + options: + - cooking_hood_enum_type_intensive_stage_intensive_stage_off + - cooking_hood_enum_type_intensive_stage_intensive_stage1 + - cooking_hood_enum_type_intensive_stage_intensive_stage2 + oven_options: + collapsed: true + fields: + cooking_oven_option_setpoint_temperature: + example: 180 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: °C/°F + b_s_h_common_option_duration: + example: "30:00" + required: false + selector: + time: + cooking_oven_option_fast_pre_heat: + example: false + required: false + selector: + boolean: + warming_drawer_options: + collapsed: true + fields: + cooking_oven_option_warming_level: + example: cooking_oven_enum_type_warming_level_medium + required: false + selector: + select: + mode: dropdown + translation_key: warming_level + options: + - cooking_oven_enum_type_warming_level_low + - cooking_oven_enum_type_warming_level_medium + - cooking_oven_enum_type_warming_level_high + washer_options: + collapsed: true + fields: + laundry_care_washer_option_temperature: + example: laundry_care_washer_enum_type_temperature_g_c40 + required: false + selector: + select: + mode: dropdown + translation_key: washer_temperature + options: + - laundry_care_washer_enum_type_temperature_cold + - laundry_care_washer_enum_type_temperature_g_c20 + - laundry_care_washer_enum_type_temperature_g_c30 + - laundry_care_washer_enum_type_temperature_g_c40 + - laundry_care_washer_enum_type_temperature_g_c50 + - laundry_care_washer_enum_type_temperature_g_c60 + - laundry_care_washer_enum_type_temperature_g_c70 + - laundry_care_washer_enum_type_temperature_g_c80 + - laundry_care_washer_enum_type_temperature_g_c90 + - laundry_care_washer_enum_type_temperature_ul_cold + - laundry_care_washer_enum_type_temperature_ul_warm + - laundry_care_washer_enum_type_temperature_ul_hot + - laundry_care_washer_enum_type_temperature_ul_extra_hot + laundry_care_washer_option_spin_speed: + example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + required: false + selector: + select: + mode: dropdown + translation_key: spin_speed + options: + - laundry_care_washer_enum_type_spin_speed_off + - laundry_care_washer_enum_type_spin_speed_r_p_m400 + - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_ul_off + - laundry_care_washer_enum_type_spin_speed_ul_low + - laundry_care_washer_enum_type_spin_speed_ul_medium + - laundry_care_washer_enum_type_spin_speed_ul_high + b_s_h_common_option_finish_in_relative: + example: "30:00" + required: false + selector: + time: + laundry_care_washer_option_i_dos1_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_i_dos2_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_vario_perfect: + example: laundry_care_common_enum_type_vario_perfect_eco_perfect + required: false + selector: + select: + mode: dropdown + translation_key: vario_perfect + options: + - laundry_care_common_enum_type_vario_perfect_off + - laundry_care_common_enum_type_vario_perfect_eco_perfect + - laundry_care_common_enum_type_vario_perfect_speed_perfect pause_program: fields: device_id: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d07cfcdf854..38fdd6f6ec3 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -95,6 +95,9 @@ }, "fetch_api_error": { "message": "Error obtaining data from the API: {error}" + }, + "required_program_or_one_option_at_least": { + "message": "A program or at least one of the possible options for a program should be specified" } }, "issues": { @@ -105,6 +108,343 @@ "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + }, + "deprecated_set_program_and_option_actions": { + "title": "The executed action is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]", + "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}." + } + } + } + } + }, + "selector": { + "affects_to": { + "options": { + "active_program": "Active program", + "selected_program": "Selected program" + } + }, + "programs": { + "options": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", + "consumer_products_cleaning_robot_program_basic_go_home": "Go home", + "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", + "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", + "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", + "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", + "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", + "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", + "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", + "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", + "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", + "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", + "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", + "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", + "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", + "dishcare_dishwasher_program_auto_1": "Auto 1", + "dishcare_dishwasher_program_auto_2": "Auto 2", + "dishcare_dishwasher_program_auto_3": "Auto 3", + "dishcare_dishwasher_program_eco_50": "Eco 50ºC", + "dishcare_dishwasher_program_quick_45": "Quick 45ºC", + "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", + "dishcare_dishwasher_program_normal_65": "Normal 65ºC", + "dishcare_dishwasher_program_glas_40": "Glass 40ºC", + "dishcare_dishwasher_program_glass_care": "Glass care", + "dishcare_dishwasher_program_night_wash": "Night wash", + "dishcare_dishwasher_program_quick_65": "Quick 65ºC", + "dishcare_dishwasher_program_normal_45": "Normal 45ºC", + "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", + "dishcare_dishwasher_program_auto_half_load": "Auto half load", + "dishcare_dishwasher_program_intensiv_power": "Intensive power", + "dishcare_dishwasher_program_magic_daily": "Magic daily", + "dishcare_dishwasher_program_super_60": "Super 60ºC", + "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", + "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", + "dishcare_dishwasher_program_machine_care": "Machine care", + "dishcare_dishwasher_program_steam_fresh": "Steam fresh", + "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", + "dishcare_dishwasher_program_mixed_load": "Mixed load", + "laundry_care_dryer_program_cotton": "Cotton", + "laundry_care_dryer_program_synthetic": "Synthetic", + "laundry_care_dryer_program_mix": "Mix", + "laundry_care_dryer_program_blankets": "Blankets", + "laundry_care_dryer_program_business_shirts": "Business shirts", + "laundry_care_dryer_program_down_feathers": "Down feathers", + "laundry_care_dryer_program_hygiene": "Hygiene", + "laundry_care_dryer_program_jeans": "Jeans", + "laundry_care_dryer_program_outdoor": "Outdoor", + "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", + "laundry_care_dryer_program_towels": "Towels", + "laundry_care_dryer_program_delicates": "Delicates", + "laundry_care_dryer_program_super_40": "Super 40ºC", + "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", + "laundry_care_dryer_program_pillow": "Pillow", + "laundry_care_dryer_program_anti_shrink": "Anti shrink", + "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", + "laundry_care_dryer_program_time_cold": "Cold (variable time)", + "laundry_care_dryer_program_time_warm": "Warm (variable time)", + "laundry_care_dryer_program_in_basket": "In basket", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", + "laundry_care_dryer_program_dessous": "Dessous", + "cooking_common_program_hood_automatic": "Automatic", + "cooking_common_program_hood_venting": "Venting", + "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", + "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", + "cooking_oven_program_heating_mode_hot_air": "Hot air", + "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", + "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", + "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", + "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", + "cooking_oven_program_heating_mode_slow_cook": "Slow cook", + "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", + "cooking_oven_program_heating_mode_keep_warm": "Keep warm", + "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_defrost": "Defrost", + "cooking_oven_program_heating_mode_proof": "Proof", + "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", + "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", + "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", + "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", + "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", + "cooking_oven_program_microwave_90_watt": "90 Watt", + "cooking_oven_program_microwave_180_watt": "180 Watt", + "cooking_oven_program_microwave_360_watt": "360 Watt", + "cooking_oven_program_microwave_600_watt": "600 Watt", + "cooking_oven_program_microwave_900_watt": "900 Watt", + "cooking_oven_program_microwave_1000_watt": "1000 Watt", + "cooking_oven_program_microwave_max": "Max", + "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", + "laundry_care_washer_program_cotton": "Cotton", + "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", + "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_program_cotton_colour": "Cotton color", + "laundry_care_washer_program_easy_care": "Easy care", + "laundry_care_washer_program_mix": "Mix", + "laundry_care_washer_program_mix_night_wash": "Mix night wash", + "laundry_care_washer_program_delicates_silk": "Delicates silk", + "laundry_care_washer_program_wool": "Wool", + "laundry_care_washer_program_sensitive": "Sensitive", + "laundry_care_washer_program_auto_30": "Auto 30ºC", + "laundry_care_washer_program_auto_40": "Auto 40ºC", + "laundry_care_washer_program_auto_60": "Auto 60ºC", + "laundry_care_washer_program_chiffon": "Chiffon", + "laundry_care_washer_program_curtains": "Curtains", + "laundry_care_washer_program_dark_wash": "Dark wash", + "laundry_care_washer_program_dessous": "Dessous", + "laundry_care_washer_program_monsoon": "Monsoon", + "laundry_care_washer_program_outdoor": "Outdoor", + "laundry_care_washer_program_plush_toy": "Plush toy", + "laundry_care_washer_program_shirts_blouses": "Shirts blouses", + "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_towels": "Towels", + "laundry_care_washer_program_water_proof": "Water proof", + "laundry_care_washer_program_power_speed_59": "Power speed <59 min", + "laundry_care_washer_program_super_153045_super_15": "Super 15 min", + "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", + "laundry_care_washer_program_down_duvet_duvet": "Down duvet", + "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", + "laundry_care_washer_program_drum_clean": "Drum clean", + "laundry_care_washer_dryer_program_cotton": "Cotton", + "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_dryer_program_mix": "Mix", + "laundry_care_washer_dryer_program_easy_care": "Easy care", + "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", + "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + } + }, + "available_maps": { + "options": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3" + } + }, + "cleaning_mode": { + "options": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power" + } + }, + "bean_amount": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "Very mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "Mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "Mild +", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "Normal", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "Normal +", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "Strong", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "Strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "Very strong", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "Very strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "Extra strong", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "Double shot", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "Double shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "Double shot ++", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "Triple shot", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "Triple shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "Coffee ground" + } + }, + "coffee_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "88ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "92ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "94ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "96ºC" + } + }, + "bean_container": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "Right", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "Left" + } + }, + "flow_rate": { + "options": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" + } + }, + "hot_water_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "65ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "70ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "75ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "80ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "85ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "97ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "122ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "131ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "140ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "149ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "158ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "167ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "176ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "185ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "194ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "203ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "Max" + } + }, + "drying_target": { + "options": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" + } + }, + "venting_level": { + "options": { + "cooking_hood_enum_type_stage_fan_off": "Fan off", + "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + } + }, + "intensive_level": { + "options": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2" + } + }, + "warming_level": { + "options": { + "cooking_oven_enum_type_warming_level_low": "Low", + "cooking_oven_enum_type_warming_level_medium": "Medium", + "cooking_oven_enum_type_warming_level_high": "High" + } + }, + "washer_temperature": { + "options": { + "laundry_care_washer_enum_type_temperature_cold": "Cold", + "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", + "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", + "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "Extra hot" + } + }, + "spin_speed": { + "options": { + "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", + "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", + "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + } + }, + "vario_perfect": { + "options": { + "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" + } } }, "services": { @@ -113,8 +453,8 @@ "description": "Selects a program and starts it.", "fields": { "device_id": { - "name": "Device ID", - "description": "ID of the device." + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "Program", "description": "Program to select." }, "key": { "name": "Option key", "description": "Key of the option." }, @@ -130,8 +470,8 @@ "description": "Selects a program without starting it.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "[%key:component::home_connect::services::start_program::fields::program::name%]", @@ -151,13 +491,197 @@ } } }, + "set_program_and_options": { + "name": "Set program and options", + "description": "Starts or selects a program with options or sets the options for the active or the selected program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "ID of the device." + }, + "affects_to": { + "name": "Affects to", + "description": "Selects if the program affected by the action should be the active or the selected program." + }, + "program": { + "name": "Program", + "description": "Program to select" + }, + "consumer_products_cleaning_robot_option_reference_map_id": { + "name": "Reference map ID", + "description": "Defines the used reference map." + }, + "consumer_products_cleaning_robot_option_cleaning_mode": { + "name": "Cleaning mode", + "description": "Defines the favoured cleaning mode." + }, + "consumer_products_coffee_maker_option_bean_amount": { + "name": "Bean amount", + "description": "Describes the bean amount of a coffee machine program." + }, + "consumer_products_coffee_maker_option_fill_quantity": { + "name": "Fill quantity", + "description": "Describes the fill quantity (in ml) of a coffee machine program." + }, + "consumer_products_coffee_maker_option_coffee_temperature": { + "name": "Coffee Temperature", + "description": "Describes the coffee temperature of a coffee machine program." + }, + "consumer_products_coffee_maker_option_bean_container": { + "name": "Bean container", + "description": "Defines the preferred bean container." + }, + "consumer_products_coffee_maker_option_flow_rate": { + "name": "Flow rate", + "description": "Defines the water-coffee contact time. The duration extends to coffee intensity." + }, + "consumer_products_coffee_maker_option_multiple_beverages": { + "name": "Multiple beverages", + "description": "Defines if double dispensing is enabled." + }, + "consumer_products_coffee_maker_option_coffee_milk_ratio": { + "name": "Coffee milk ratio", + "description": "Defines the milk amount." + }, + "consumer_products_coffee_maker_option_hot_water_temperature": { + "name": "Hot water temperature", + "description": "Defines the temperature suitable for the type of tea." + }, + "b_s_h_common_option_start_in_relative": { + "name": "Start in relative", + "description": "Defines in how many time the program should start." + }, + "dishcare_dishwasher_option_intensiv_zone": { + "name": "Intensive zone", + "description": "Defines if the cleaning is done with higher spray pressure on the lower basket for very dirty pots and pans." + }, + "dishcare_dishwasher_option_brilliance_dry": { + "name": "Brilliance dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + }, + "dishcare_dishwasher_option_vario_speed_plus": { + "name": "Vario speed plus", + "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying." + }, + "dishcare_dishwasher_option_silence_on_demand": { + "name": "Silence on demand", + "description": "Defines if the extra silent mode is activated for a selected period of time." + }, + "dishcare_dishwasher_option_half_load": { + "name": "Half load", + "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + }, + "dishcare_dishwasher_option_extra_dry": { + "name": "Extra dry", + "description": "Defines if improved drying for glasses and plasticware is enabled." + }, + "dishcare_dishwasher_option_hygiene_plus": { + "name": "Hygiene plus", + "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + }, + "dishcare_dishwasher_option_eco_dry": { + "name": "Eco dry", + "description": "Defines if the door is opened automatically for extra energy efficient and effective drying." + }, + "dishcare_dishwasher_option_zeolite_dry": { + "name": "Zeolite dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware." + }, + "laundry_care_dryer_option_drying_target": { + "name": "Drying target", + "description": "Describes the drying target for a dryer program." + }, + "cooking_hood_option_venting_level": { + "name": "Venting level", + "description": "Defines the required fan setting." + }, + "cooking_hood_option_intensive_level": { + "name": "Intensive level", + "description": "Defines the intensive setting." + }, + "cooking_oven_option_setpoint_temperature": { + "name": "Setpoint temperature", + "description": "Defines the target cavity temperature, which will be hold by the oven." + }, + "b_s_h_common_option_duration": { + "name": "Duration", + "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + }, + "cooking_oven_option_fast_pre_heat": { + "name": "Fast pre-heat", + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + }, + "cooking_oven_option_warming_level": { + "name": "Warming level", + "description": "Defines the level of the warming drawer." + }, + "laundry_care_washer_option_temperature": { + "name": "Temperature", + "description": "Defines the temperature of the washing program." + }, + "laundry_care_washer_option_spin_speed": { + "name": "Spin speed", + "description": "Defines the spin speed of a washer program." + }, + "b_s_h_common_option_finish_in_relative": { + "name": "Finish in relative", + "description": "Defines when the program should end in seconds." + }, + "laundry_care_washer_option_i_dos1_active": { + "name": "i-Dos 1 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)" + }, + "laundry_care_washer_option_i_dos2_active": { + "name": "i-Dos 2 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)" + }, + "laundry_care_washer_option_vario_perfect": { + "name": "Vario perfect", + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect)." + } + }, + "sections": { + "cleaning_robot_options": { + "name": "Cleaning robot options", + "description": "Options for cleaning robots." + }, + "coffee_maker_options": { + "name": "Coffee maker options", + "description": "Options for coffee makers." + }, + "dish_washer_options": { + "name": "Dishwasher options", + "description": "Options for dishwashers." + }, + "dryer_options": { + "name": "Dryer options", + "description": "Options for dryers (and washer dryers)." + }, + "hood_options": { + "name": "Hood options", + "description": "Options for hoods." + }, + "oven_options": { + "name": "Oven options", + "description": "Options for ovens." + }, + "warming_drawer_options": { + "name": "Warming drawer options", + "description": "Options for warming drawers." + }, + "washer_options": { + "name": "Washer options", + "description": "Options for washers (and washer dryers)." + } + } + }, "pause_program": { "name": "Pause program", "description": "Pauses the current running program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -166,8 +690,8 @@ "description": "Resumes a paused program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -176,8 +700,8 @@ "description": "Sets an option for the active program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", @@ -191,18 +715,18 @@ }, "set_option_selected": { "name": "Set selected program option", - "description": "Sets an option for the selected program.", + "description": "Sets options for the selected program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { - "name": "Key", + "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" }, "value": { - "name": "Value", + "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" } } @@ -212,8 +736,8 @@ "description": "Changes a setting.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", "description": "Key of the setting." }, "value": { "name": "Value", "description": "Value of the setting." } @@ -307,319 +831,319 @@ "selected_program": { "name": "Selected program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", - "consumer_products_cleaning_robot_program_basic_go_home": "Go home", - "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", - "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", - "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", - "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", - "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", - "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", - "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", - "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", - "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", - "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", - "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", - "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", - "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", - "dishcare_dishwasher_program_auto_1": "Auto 1", - "dishcare_dishwasher_program_auto_2": "Auto 2", - "dishcare_dishwasher_program_auto_3": "Auto 3", - "dishcare_dishwasher_program_eco_50": "Eco 50ºC", - "dishcare_dishwasher_program_quick_45": "Quick 45ºC", - "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", - "dishcare_dishwasher_program_normal_65": "Normal 65ºC", - "dishcare_dishwasher_program_glas_40": "Glass 40ºC", - "dishcare_dishwasher_program_glass_care": "Glass care", - "dishcare_dishwasher_program_night_wash": "Night wash", - "dishcare_dishwasher_program_quick_65": "Quick 65ºC", - "dishcare_dishwasher_program_normal_45": "Normal 45ºC", - "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", - "dishcare_dishwasher_program_auto_half_load": "Auto half load", - "dishcare_dishwasher_program_intensiv_power": "Intensive power", - "dishcare_dishwasher_program_magic_daily": "Magic daily", - "dishcare_dishwasher_program_super_60": "Super 60ºC", - "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", - "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", - "dishcare_dishwasher_program_machine_care": "Machine care", - "dishcare_dishwasher_program_steam_fresh": "Steam fresh", - "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", - "dishcare_dishwasher_program_mixed_load": "Mixed load", - "laundry_care_dryer_program_cotton": "Cotton", - "laundry_care_dryer_program_synthetic": "Synthetic", - "laundry_care_dryer_program_mix": "Mix", - "laundry_care_dryer_program_blankets": "Blankets", - "laundry_care_dryer_program_business_shirts": "Business shirts", - "laundry_care_dryer_program_down_feathers": "Down feathers", - "laundry_care_dryer_program_hygiene": "Hygiene", - "laundry_care_dryer_program_jeans": "Jeans", - "laundry_care_dryer_program_outdoor": "Outdoor", - "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", - "laundry_care_dryer_program_towels": "Towels", - "laundry_care_dryer_program_delicates": "Delicates", - "laundry_care_dryer_program_super_40": "Super 40ºC", - "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", - "laundry_care_dryer_program_pillow": "Pillow", - "laundry_care_dryer_program_anti_shrink": "Anti shrink", - "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", - "laundry_care_dryer_program_time_cold": "Cold (variable time)", - "laundry_care_dryer_program_time_warm": "Warm (variable time)", - "laundry_care_dryer_program_in_basket": "In basket", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", - "laundry_care_dryer_program_dessous": "Dessous", - "cooking_common_program_hood_automatic": "Automatic", - "cooking_common_program_hood_venting": "Venting", - "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", - "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", - "cooking_oven_program_heating_mode_hot_air": "Hot air", - "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", - "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", - "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", - "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", - "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", - "cooking_oven_program_heating_mode_slow_cook": "Slow cook", - "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", - "cooking_oven_program_heating_mode_keep_warm": "Keep warm", - "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", - "cooking_oven_program_heating_mode_desiccation": "Desiccation", - "cooking_oven_program_heating_mode_defrost": "Defrost", - "cooking_oven_program_heating_mode_proof": "Proof", - "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", - "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", - "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", - "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", - "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", - "cooking_oven_program_microwave_90_watt": "90 Watt", - "cooking_oven_program_microwave_180_watt": "180 Watt", - "cooking_oven_program_microwave_360_watt": "360 Watt", - "cooking_oven_program_microwave_600_watt": "600 Watt", - "cooking_oven_program_microwave_900_watt": "900 Watt", - "cooking_oven_program_microwave_1000_watt": "1000 Watt", - "cooking_oven_program_microwave_max": "Max", - "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", - "laundry_care_washer_program_cotton": "Cotton", - "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", - "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", - "laundry_care_washer_program_cotton_colour": "Cotton color", - "laundry_care_washer_program_easy_care": "Easy care", - "laundry_care_washer_program_mix": "Mix", - "laundry_care_washer_program_mix_night_wash": "Mix night wash", - "laundry_care_washer_program_delicates_silk": "Delicates silk", - "laundry_care_washer_program_wool": "Wool", - "laundry_care_washer_program_sensitive": "Sensitive", - "laundry_care_washer_program_auto_30": "Auto 30ºC", - "laundry_care_washer_program_auto_40": "Auto 40ºC", - "laundry_care_washer_program_auto_60": "Auto 60ºC", - "laundry_care_washer_program_chiffon": "Chiffon", - "laundry_care_washer_program_curtains": "Curtains", - "laundry_care_washer_program_dark_wash": "Dark wash", - "laundry_care_washer_program_dessous": "Dessous", - "laundry_care_washer_program_monsoon": "Monsoon", - "laundry_care_washer_program_outdoor": "Outdoor", - "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", - "laundry_care_washer_program_towels": "Towels", - "laundry_care_washer_program_water_proof": "Water proof", - "laundry_care_washer_program_power_speed_59": "Power speed <60 min", - "laundry_care_washer_program_super_153045_super_15": "Super 15 min", - "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", - "laundry_care_washer_program_down_duvet_duvet": "Down duvet", - "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", - "laundry_care_washer_program_drum_clean": "Drum clean", - "laundry_care_washer_dryer_program_cotton": "Cotton", - "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC", - "laundry_care_washer_dryer_program_mix": "Mix", - "laundry_care_washer_dryer_program_easy_care": "Easy care", - "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", - "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } }, "active_program": { "name": "Active program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]", - "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]", - "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]", - "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", - "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", - "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]", - "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]", - "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]", - "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", - "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", - "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]", - "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]", - "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", - "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]", - "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]", - "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]", - "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]", - "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]", - "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]", - "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]", - "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]", - "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]", - "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]", - "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]", - "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]", - "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]", - "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]", - "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]", - "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]", - "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]", - "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]", - "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]", - "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]", - "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]", - "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]", - "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]", - "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]", - "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]", - "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]", - "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]", - "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]", - "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]", - "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]", - "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]", - "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]", - "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]", - "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]", - "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]", - "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]", - "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]", - "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]", - "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]", - "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]", - "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]", - "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]", - "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]", - "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]", - "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", - "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]", - "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]", - "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]", - "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]", - "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]", - "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]", - "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]", - "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]", - "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", - "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]", - "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]", - "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]", - "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]", - "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]", - "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]", - "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]", - "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]", - "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]", - "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]", - "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]", - "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]", - "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]", - "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]", - "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]", - "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]", - "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]", - "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]", - "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]", - "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]", - "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]", - "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]", - "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]", - "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]", - "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]", - "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]", - "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]", - "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]", - "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]", - "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]", - "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]", - "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]", - "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]", - "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]", - "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]", - "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]", - "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]", - "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]", - "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]", - "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]", - "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]", - "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]", - "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]", - "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]", - "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]", - "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]", - "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]", - "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]", - "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]", - "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]", - "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]", - "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]", - "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]", - "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]", - "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]", - "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]", - "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]", - "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]", - "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } } }, diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 4061d5ed863..7b74c2290c3 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, @@ -199,13 +200,13 @@ def _get_set_program_side_effect( return set_program_side_effect -def _get_set_key_value_side_effect( - event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +def _get_set_setting_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], ): - """Set program options side effect.""" + """Set settings side effect.""" - async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: - event_key = EventKey(kwargs[parameter_key]) + async def set_settings_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["setting_key"]) await event_queue.put( [ EventMessage( @@ -227,7 +228,48 @@ def _get_set_key_value_side_effect( ] ) - return set_key_value_side_effect + return set_settings_side_effect + + +def _get_set_program_options_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], +): + """Set programs side effect.""" + + async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey(option.key), + raw_key=option.key.value, + timestamp=0, + level="", + handling="", + value=option.value, + ) + for option in ( + cast(ArrayOfOptions, kwargs["array_of_options"]).options + if "array_of_options" in kwargs + else [ + Option( + kwargs["option_key"], + kwargs["value"], + unit=kwargs["unit"], + ) + ] + ) + ] + ), + ), + ] + ) + + return set_program_options_side_effect async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: @@ -319,13 +361,19 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ), ) mock.set_active_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_active_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_selected_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_selected_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_setting = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + side_effect=_get_set_setting_side_effect(event_queue), ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) @@ -363,7 +411,9 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_active_program_options = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_options = AsyncMock(side_effect=exception) mock.set_setting = AsyncMock(side_effect=exception) mock.get_settings = AsyncMock(side_effect=exception) mock.get_setting = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr new file mode 100644 index 00000000000..581eca66cb8 --- /dev/null +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_set_program_and_options[service_call0-set_selected_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 1800, + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call1-start_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal', + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call2-set_active_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 60, + }), + ]), + }), + }), + ) +# --- +# name: test_set_program_and_options[service_call3-set_selected_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 35, + }), + ]), + }), + }), + ) +# --- diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 009c40b662d..e7380d0e255 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,6 +1,7 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest import requests_mock import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -22,6 +24,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -34,8 +37,9 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -SERVICE_KV_CALL_PARAMS = [ +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ { "domain": DOMAIN, "service": "set_option_active", @@ -57,6 +61,10 @@ SERVICE_KV_CALL_PARAMS = [ }, "blocking": True, }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, { "domain": DOMAIN, "service": "change_setting", @@ -125,6 +133,62 @@ SERVICE_APPLIANCE_METHOD_MAPPING = { "start_program": "start_program", } +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": "00:30:00", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + async def test_entry_setup( hass: HomeAssistant, @@ -244,7 +308,7 @@ async def test_client_error( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services( +async def test_key_value_services( service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -273,11 +337,188 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + issue_id = "deprecated_set_program_and_option_actions" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +async def test_required_program_or_at_least_an_option( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + "Test that the set_program_and_options does raise an exception if no program nor options are set." + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + with pytest.raises( + ServiceValidationError, + ): + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + { + "device_id": device_entry.id, + "affects_to": "selected_program", + }, + True, + ) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services_exception( +async def test_services_exception_device_id( service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, @@ -348,6 +589,40 @@ async def test_services_appliance_not_found( await hass.services.async_call(**service_call) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) + + async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From c090fbfbadff4e01411722320113c6ddb1ba0c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 20:21:30 +0100 Subject: [PATCH 081/155] Add binary sensor platform to LetPot integration (#138554) --- homeassistant/components/letpot/__init__.py | 7 +- .../components/letpot/binary_sensor.py | 122 +++++++ homeassistant/components/letpot/icons.json | 20 ++ .../components/letpot/quality_scale.yaml | 2 +- homeassistant/components/letpot/strings.json | 17 + tests/components/letpot/__init__.py | 18 +- tests/components/letpot/conftest.py | 63 +++- .../letpot/snapshots/test_binary_sensor.ambr | 337 ++++++++++++++++++ tests/components/letpot/test_binary_sensor.py | 32 ++ 9 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/letpot/binary_sensor.py create mode 100644 tests/components/letpot/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/letpot/test_binary_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index dc322d5641b..50c73f949a3 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,12 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py new file mode 100644 index 00000000000..bfc7a5ab4a7 --- /dev/null +++ b/homeassistant/components/letpot/binary_sensor.py @@ -0,0 +1,122 @@ +"""Support for LetPot binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LetPotBinarySensorEntityDescription( + LetPotEntityDescription, BinarySensorEntityDescription +): + """Describes a LetPot binary sensor entity.""" + + is_on_fn: Callable[[LetPotDeviceStatus], bool] + + +BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( + LetPotBinarySensorEntityDescription( + key="low_nutrients", + translation_key="low_nutrients", + is_on_fn=lambda status: bool(status.errors.low_nutrients), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.low_nutrients is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="low_water", + translation_key="low_water", + is_on_fn=lambda status: bool(status.errors.low_water), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None, + ), + LetPotBinarySensorEntityDescription( + key="pump", + translation_key="pump", + is_on_fn=lambda status: status.pump_status == 1, + device_class=BinarySensorDeviceClass.RUNNING, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_STATUS + in coordinator.device_client.device_features + ), + ), + LetPotBinarySensorEntityDescription( + key="pump_error", + translation_key="pump_error", + is_on_fn=lambda status: bool(status.errors.pump_malfunction), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.pump_malfunction is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="refill_error", + translation_key="refill_error", + is_on_fn=lambda status: bool(status.errors.refill_error), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.refill_error is not None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot binary sensor entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity): + """Defines a LetPot binary sensor entity.""" + + entity_description: LetPotBinarySensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotBinarySensorEntityDescription, + ) -> None: + """Initialize LetPot binary sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def is_on(self) -> bool: + """Return if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 60cba78fa1c..43541b57150 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "low_nutrients": { + "default": "mdi:beaker-alert", + "state": { + "off": "mdi:beaker" + } + }, + "low_water": { + "default": "mdi:water-percent-alert", + "state": { + "off": "mdi:water-percent" + } + }, + "pump": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + }, "sensor": { "water_level": { "default": "mdi:water-percent" diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0fdaca18717..9804a5ec3a4 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -61,7 +61,7 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 0cb79ce711c..cdc5a36a15f 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,23 @@ } }, "entity": { + "binary_sensor": { + "low_nutrients": { + "name": "Low nutrients" + }, + "low_water": { + "name": "Low water" + }, + "pump": { + "name": "Pump" + }, + "pump_error": { + "name": "Pump error" + }, + "refill_error": { + "name": "Refill error" + } + }, "sensor": { "water_level": { "name": "Water level" diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index d4570ce44be..6e73bb430cf 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -30,7 +30,7 @@ AUTHENTICATION = AuthenticationInfo( email="email@example.com", ) -STATUS = LetPotDeviceStatus( +MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, light_mode=1, @@ -49,3 +49,19 @@ STATUS = LetPotDeviceStatus( water_mode=1, water_level=100, ) + +SE_STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[], # Not used by integration, and it requires a real device to get + system_on=True, + system_sound=False, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 454d4e235db..25974b2d78a 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice +from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus import pytest from homeassistant.components.letpot.const import ( @@ -15,11 +15,42 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION, STATUS +from . import AUTHENTICATION, MAX_STATUS, SE_STATUS from tests.common import MockConfigEntry +@pytest.fixture +def device_type() -> str: + """Return the device type to use for mock data.""" + return "LPH63" + + +def _mock_device_features(device_type: str) -> DeviceFeature: + """Return mock device feature support for the given type.""" + if device_type == "LPH31": + return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + if device_type == "LPH63": + return ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + raise ValueError(f"No mock data for device type {device_type}") + + +def _mock_device_status(device_type: str) -> LetPotDeviceStatus: + """Return mock device status for the given type.""" + if device_type == "LPH31": + return SE_STATUS + if device_type == "LPH63": + return MAX_STATUS + raise ValueError(f"No mock data for device type {device_type}") + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -30,7 +61,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_client() -> Generator[AsyncMock]: +def mock_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotClient.""" with ( patch( @@ -47,9 +78,9 @@ def mock_client() -> Generator[AsyncMock]: client.refresh_token.return_value = AUTHENTICATION client.get_devices.return_value = [ LetPotDevice( - serial_number="LPH63ABCD", + serial_number=f"{device_type}ABCD", name="Garden", - device_type="LPH63", + device_type=device_type, is_online=True, is_remote=False, ) @@ -58,23 +89,17 @@ def mock_client() -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client() -> Generator[AsyncMock]: +def mock_device_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( "homeassistant.components.letpot.coordinator.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS - | DeviceFeature.NUTRIENT_BUTTON - | DeviceFeature.PUMP_AUTO - | DeviceFeature.PUMP_STATUS - | DeviceFeature.TEMPERATURE - | DeviceFeature.WATER_LEVEL - ) - device_client.device_model_code = "LPH63" - device_client.device_model_name = "LetPot Max" + device_client.device_features = _mock_device_features(device_type) + device_client.device_model_code = device_type + device_client.device_model_name = f"LetPot {device_type}" + device_status = _mock_device_status(device_type) subscribe_callbacks: list[Callable] = [] @@ -84,11 +109,11 @@ def mock_device_client() -> Generator[AsyncMock]: def status_side_effect() -> None: # Deliver a status update to any subscribers, like the real client for callback in subscribe_callbacks: - callback(STATUS) + callback(device_status) device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = STATUS - device_client.last_status.return_value = STATUS + device_client.get_current_status.return_value = device_status + device_client.last_status.return_value = device_status device_client.request_status_update.side_effect = status_side_effect device_client.subscribe.side_effect = subscribe_side_effect diff --git a/tests/components/letpot/snapshots/test_binary_sensor.ambr b/tests/components/letpot/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..121cf4e3f82 --- /dev/null +++ b/tests/components/letpot/snapshots/test_binary_sensor.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Pump error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low nutrients', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_nutrients', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low nutrients', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refill error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refill_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Refill error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py new file mode 100644 index 00000000000..03ce1bee1a5 --- /dev/null +++ b/tests/components/letpot/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test binary sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH63", "LPH31"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test binary sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 58797a14e7c54be566eb869dd59cc5410923155c Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:48:19 +0100 Subject: [PATCH 082/155] Add 6 new sensors to qBittorrent integration (#138446) Co-authored-by: Josef Zweck --- .../components/qbittorrent/sensor.py | 108 +++++++++++++++++- .../components/qbittorrent/strings.json | 23 ++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 9f4610cff64..23ec485fcd4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" +SENSOR_TYPE_CONNECTION_STATUS = "connection_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit" +SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit" +SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download" +SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload" +SENSOR_TYPE_GLOBAL_RATIO = "global_ratio" SENSOR_TYPE_ALL_TORRENTS = "all_torrents" SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" @@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE -def get_dl(coordinator: QBittorrentDataCoordinator) -> int: +def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(str, server_state.get("connection_status")) + + +def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current download speed.""" server_state = cast(Mapping, coordinator.data.get("server_state")) return cast(int, server_state.get("dl_info_speed")) -def get_up(coordinator: QBittorrentDataCoordinator) -> int: +def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current upload speed.""" server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) return cast(int, server_state.get("up_info_speed")) +def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_rate_limit")) + + +def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_rate_limit")) + + +def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_dl")) + + +def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_ul")) + + +def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(float, server_state.get("global_ratio")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], value_fn=get_state, ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_CONNECTION_STATUS, + translation_key="connection_status", + device_class=SensorDeviceClass.ENUM, + options=["connected", "firewalled", "disconnected"], + value_fn=get_connection_status, + ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, translation_key="download_speed", @@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_dl, + value_fn=get_download_speed, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_up, + value_fn=get_upload_speed, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT, + translation_key="download_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_download_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT, + translation_key="upload_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_upload_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_DOWNLOAD, + translation_key="alltime_download", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES, + value_fn=get_alltime_download, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_UPLOAD, + translation_key="alltime_upload", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement="B", + suggested_display_precision=2, + suggested_unit_of_measurement="TiB", + value_fn=get_alltime_upload, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_GLOBAL_RATIO, + translation_key="global_ratio", + state_class=SensorStateClass.MEASUREMENT, + value_fn=get_global_ratio, + entity_registry_enabled_default=False, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 9c9ee371737..83d93766ee4 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -26,6 +26,21 @@ "upload_speed": { "name": "Upload speed" }, + "download_speed_limit": { + "name": "Download speed limit" + }, + "upload_speed_limit": { + "name": "Upload speed limit" + }, + "alltime_download": { + "name": "Alltime download" + }, + "alltime_upload": { + "name": "Alltime upload" + }, + "global_ratio": { + "name": "Global ratio" + }, "current_status": { "name": "Status", "state": { @@ -35,6 +50,14 @@ "downloading": "Downloading" } }, + "connection_status": { + "name": "Connection status", + "state": { + "connected": "Conencted", + "firewalled": "Firewalled", + "disconnected": "Disconnected" + } + }, "active_torrents": { "name": "Active torrents", "unit_of_measurement": "torrents" From b916fbe1fc50b375233677079da97a573aebadf1 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 14 Feb 2025 12:50:51 -0700 Subject: [PATCH 083/155] Add time entity to balboa (#138248) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 8 + homeassistant/components/balboa/time.py | 56 ++++++ tests/components/balboa/conftest.py | 5 + .../balboa/snapshots/test_time.ambr | 189 ++++++++++++++++++ tests/components/balboa/test_time.py | 72 +++++++ 6 files changed, 331 insertions(+) create mode 100644 homeassistant/components/balboa/time.py create mode 100644 tests/components/balboa/snapshots/test_time.ambr create mode 100644 tests/components/balboa/test_time.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index c982d59d513..78bf6f7cda7 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index c00567a6052..0262f26f4bd 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -78,6 +78,14 @@ "high": "High" } } + }, + "time": { + "filter_cycle_start": { + "name": "Filter cycle {index} start" + }, + "filter_cycle_end": { + "name": "Filter cycle {index} end" + } } } } diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py new file mode 100644 index 00000000000..83467de8777 --- /dev/null +++ b/homeassistant/components/balboa/time.py @@ -0,0 +1,56 @@ +"""Support for Balboa times.""" + +from __future__ import annotations + +from datetime import time +import itertools +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.time import TimeEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FILTER_CYCLE = "filter_cycle_" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's times.""" + spa = entry.runtime_data + async_add_entities( + BalboaTimeEntity(spa, index, period) + for index, period in itertools.product((1, 2), ("start", "end")) + ) + + +class BalboaTimeEntity(BalboaEntity, TimeEntity): + """Representation of a Balboa time entity.""" + + entity_category = EntityCategory.CONFIG + + def __init__(self, spa: SpaClient, index: int, period: str) -> None: + """Initialize a Balboa time entity.""" + super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}") + self.index = index + self.period = period + self._attr_translation_key = f"{FILTER_CYCLE}{period}" + self._attr_translation_placeholders = {"index": str(index)} + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}") + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + args: dict[str, Any] = {self.period: value} + await self._client.configure_filter_cycle(self.index, **args) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 0bb8b2cd468..3a3561f15cf 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator +from datetime import time from unittest.mock import AsyncMock, MagicMock, patch from pybalboa.enums import HeatMode, LowHighRange @@ -48,7 +49,11 @@ def client_fixture() -> Generator[MagicMock]: client.blowers = [] client.circulation_pump.state = 0 client.filter_cycle_1_running = False + client.filter_cycle_1_start = time(8, 0) + client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_start = time(19, 0) + client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 client.temperature = 10 client.temperature_minimum = 10 diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr new file mode 100644 index 00000000000..6b27717e2d3 --- /dev/null +++ b/tests/components/balboa/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_times[time.fakespa_filter_cycle_1_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '09:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21:30:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py new file mode 100644 index 00000000000..21778d08e2d --- /dev/null +++ b/tests/components/balboa/test_time.py @@ -0,0 +1,72 @@ +"""Tests of the times of the balboa integration.""" + +from __future__ import annotations + +from datetime import time +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_TIME = "time.fakespa_" + + +async def test_times( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa times.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.TIME]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("filter_cycle", "period", "value"), + [ + (1, "start", "08:00:00"), + (1, "end", "09:00:00"), + (2, "start", "19:00:00"), + (2, "end", "21:30:00"), + ], +) +async def test_time( + hass: HomeAssistant, client: MagicMock, filter_cycle: int, period: str, value: str +) -> None: + """Test spa filter cycle time.""" + await init_integration(hass) + + time_entity = f"{ENTITY_TIME}filter_cycle_{filter_cycle}_{period}" + + # check the expected state of the time entity + state = hass.states.get(time_entity) + assert state.state == value + + new_time = time(hour=7, minute=0) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: new_time}, + blocking=True, + target={ATTR_ENTITY_ID: time_entity}, + ) + + # check we made a call with the right parameters + client.configure_filter_cycle.assert_called_with(filter_cycle, **{period: new_time}) From 28dd44504e614eccf9bb2bf7d84ba5060910c426 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 14:42:36 -0600 Subject: [PATCH 084/155] Bump aioesphomeapi to 29.0.2 (#138549) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.0.0...v29.0.2 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 185f9ea5cf0..8f9f06e6967 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.0.0", + "aioesphomeapi==29.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 43f850d14ce..447166213c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2877dfacfe..daf8ff556c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 From e16343ed727ac1e105105bae4445ac8178daa5c2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 15:41:45 -0600 Subject: [PATCH 085/155] Prevent voice wizard from crashing for wyoming/voip (#138547) * Prevent voice wizard from crashing for wyoming/voip * Use stub configuration in websocket API --- .../assist_satellite/websocket_api.py | 12 ++++++- .../assist_satellite/test_websocket_api.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 6cd7af2bbdb..4fc1708b866 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -19,6 +19,7 @@ from .const import ( DOMAIN, AssistSatelliteEntityFeature, ) +from .entity import AssistSatelliteConfiguration CONNECTION_TEST_TIMEOUT = 30 @@ -91,7 +92,16 @@ def websocket_get_configuration( ) return - config_dict = asdict(satellite.async_get_configuration()) + try: + config_dict = asdict(satellite.async_get_configuration()) + except NotImplementedError: + # Stub configuration + config_dict = asdict( + AssistSatelliteConfiguration( + available_wake_words=[], active_wake_words=[], max_active_wake_words=1 + ) + ) + config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 257961a5b32..f0a8f02fc50 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -313,6 +313,37 @@ async def test_get_configuration( } +async def test_get_configuration_not_implemented( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting stub satellite configuration when the entity doesn't implement the method.""" + ws_client = await hass_ws_client(hass) + + with patch.object( + entity, "async_get_configuration", side_effect=NotImplementedError() + ): + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Stub configuration + assert msg["result"] == { + "active_wake_words": [], + "available_wake_words": [], + "max_active_wake_words": 1, + "pipeline_entity_id": None, + "vad_entity_id": None, + } + + async def test_set_wake_words( hass: HomeAssistant, init_components: ConfigEntry, From 4a4c2ff55211498f2344860d6e88472c391b5acd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 16:17:35 -0800 Subject: [PATCH 086/155] Bump zeroconf to 0.144.3 (#138553) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index ddc74fba8bf..7a17c0dc5c3 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.144.1"] + "requirements": ["zeroconf==0.144.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b7592bf0f05..7aa76de2620 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.144.1 +zeroconf==0.144.3 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 553ced3da43..66b25b75f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.144.1" + "zeroconf==0.144.3" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2b7290fa042..2cbd3780eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.144.1 +zeroconf==0.144.3 diff --git a/requirements_all.txt b/requirements_all.txt index 447166213c3..ccc401d4f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf8ff556c8..4831ca47990 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 From 30a6a6ad4bf9ff4ea7780e33aeff2cd23c31889b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 19:51:53 -0600 Subject: [PATCH 087/155] Use language util to match intent language (#138560) --- .../components/conversation/default_agent.py | 15 +++----- .../conversation/test_default_agent.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 23c201d7579..e8bd38f5adf 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -53,6 +53,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain +from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object from .chat_log import AssistantContent, async_get_chat_log @@ -914,26 +915,20 @@ class DefaultAgent(ConversationEntity): def _load_intents(self, language: str) -> LanguageIntents | None: """Load all intents for language (run inside executor).""" intents_dict: dict[str, Any] = {} - language_variant: str | None = None supported_langs = set(get_languages()) # Choose a language variant upfront and commit to it for custom # sentences, etc. - all_language_variants = {lang.lower(): lang for lang in supported_langs} + lang_matches = language_util.matches(language, supported_langs) - # en-US, en_US, en, ... - for maybe_variant in _get_language_variations(language): - matching_variant = all_language_variants.get(maybe_variant.lower()) - if matching_variant: - language_variant = matching_variant - break - - if not language_variant: + if not lang_matches: _LOGGER.warning( "Unable to find supported language variant for %s", language ) return None + language_variant = lang_matches[0] + # Load intents for this language variant lang_variant_intents = get_intents(language_variant, json_load=json_load) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 54aa30b3fcf..d9f9917b9e0 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3178,3 +3178,39 @@ async def test_state_names_are_not_translated( mock_async_render.call_args.args[0]["state"].state == weather.ATTR_CONDITION_PARTLYCLOUDY ) + + +async def test_language_with_alternative_code( + hass: HomeAssistant, init_components +) -> None: + """Test different codes for the same language.""" + entity_ids: dict[str, str] = {} + for i, (lang_code, sentence, name) in enumerate( + ( + ("no", "slå på lampen", "lampen"), # nb + ("no-NO", "slå på lampen", "lampen"), # nb + ("iw", "הדליקי את המנורה", "מנורה"), # he + ) + ): + if not (entity_id := entity_ids.get(name)): + # Reuse entity id for the same name + entity_id = f"light.test{i}" + entity_ids[name] = entity_id + + hass.states.async_set(entity_id, "off", attributes={ATTR_FRIENDLY_NAME: name}) + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await hass.services.async_call( + "conversation", + "process", + { + conversation.ATTR_TEXT: sentence, + conversation.ATTR_LANGUAGE: lang_code, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1, f"Failed for {lang_code}, {sentence}" + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": [entity_id]} From 7a23348b1da8b7f4ba025a61f81e26d88f2c5749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 11:29:40 +0100 Subject: [PATCH 088/155] Fix and improve Home Connect strings (#138583) * Fix `hot_water_temperature` strings for tea options * Improve `deprecated_program_switch` issue description Co-authored-by: Norbert Rittel * Improve option descriptions strings Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: Norbert Rittel Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- .../components/home_connect/strings.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 38fdd6f6ec3..8bee37796ad 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -107,7 +107,7 @@ }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", - "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", @@ -346,9 +346,9 @@ }, "hot_water_temperature": { "options": { - "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "Green tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "Black tea", "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", @@ -509,7 +509,7 @@ }, "consumer_products_cleaning_robot_option_reference_map_id": { "name": "Reference map ID", - "description": "Defines the used reference map." + "description": "Defines which reference map is to be used." }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", @@ -517,15 +517,15 @@ }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", - "description": "Describes the bean amount of a coffee machine program." + "description": "Describes the amount of coffee beans used in a coffee machine program." }, "consumer_products_coffee_maker_option_fill_quantity": { "name": "Fill quantity", - "description": "Describes the fill quantity (in ml) of a coffee machine program." + "description": "Describes the amount of water (in ml) used in a coffee machine program." }, "consumer_products_coffee_maker_option_coffee_temperature": { "name": "Coffee Temperature", - "description": "Describes the coffee temperature of a coffee machine program." + "description": "Describes the coffee temperature used in a coffee machine program." }, "consumer_products_coffee_maker_option_bean_container": { "name": "Bean container", @@ -541,7 +541,7 @@ }, "consumer_products_coffee_maker_option_coffee_milk_ratio": { "name": "Coffee milk ratio", - "description": "Defines the milk amount." + "description": "Defines the amount of milk." }, "consumer_products_coffee_maker_option_hot_water_temperature": { "name": "Hot water temperature", @@ -557,7 +557,7 @@ }, "dishcare_dishwasher_option_brilliance_dry": { "name": "Brilliance dry", - "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items." }, "dishcare_dishwasher_option_vario_speed_plus": { "name": "Vario speed plus", @@ -569,7 +569,7 @@ }, "dishcare_dishwasher_option_half_load": { "name": "Half load", - "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + "description": "Defines if economical cleaning is enabled for smaller loads. This reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." }, "dishcare_dishwasher_option_extra_dry": { "name": "Extra dry", @@ -577,7 +577,7 @@ }, "dishcare_dishwasher_option_hygiene_plus": { "name": "Hygiene plus", - "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { "name": "Eco dry", @@ -605,11 +605,11 @@ }, "b_s_h_common_option_duration": { "name": "Duration", - "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + "description": "Defines the run-time of the program. Afterwards, the appliance is stopped." }, "cooking_oven_option_fast_pre_heat": { "name": "Fast pre-heat", - "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal to or higher than 100 °C or 212 °F. Otherwise, the fast pre-heat option is not activated." }, "cooking_oven_option_warming_level": { "name": "Warming level", From 91ba9b22398459b2fc9b70019d5ce29483191da8 Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Sat, 15 Feb 2025 13:13:16 +0000 Subject: [PATCH 089/155] Bump pyhive-integration to 1.0.2 (#138569) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index f68478516ab..712ccf09cae 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.1"] + "requirements": ["pyhive-integration==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccc401d4f4c..e284e9ca51f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4831ca47990..dd71eb6a8e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,7 +1622,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 From 798d2326ed608da5a4f7d88ec3e4f4077f1f69fa Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:20:51 +1000 Subject: [PATCH 090/155] Bump tesla-fleet-api to v0.9.10 (#138575) bump --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 330745316d7..bb8f6041771 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.8"] + "requirements": ["tesla-fleet-api==0.9.10"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index bfa0d831a16..dfe6d7cb3f9 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index ef4d366c779..d777cf5051e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index e284e9ca51f..958b94e1065 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2860,7 +2860,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd71eb6a8e5..03f3ea60307 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2300,7 +2300,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From cbb0dee911b04dc30d121d5c80d4758b0af04c5d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 08:22:04 -0700 Subject: [PATCH 091/155] Bump pybalboa to 1.1.3 (#138557) --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index 61cb5bbbf69..38d32adc4af 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.1.2"] + "requirements": ["pybalboa==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 958b94e1065..d3146e55fef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03f3ea60307..ac941a94b8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1505,7 +1505,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.blackbird pyblackbird==0.6 From 08f6e9cd12232dda3273a9b6eed986abc3ffcf70 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:24:43 +0100 Subject: [PATCH 092/155] Bump PyViCare to 2.43.0 (#138564) * Bump PyViCare to 2.42.1 * Bump PyViCare to 2.43.0 --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 96935ba4ba7..a5718962f55 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.1"] + "requirements": ["PyViCare==2.43.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3146e55fef..d48ed91eaae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac941a94b8a..0c9f65ab481 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From c89d8edb3cf9ed5928af2b9c84a16a2c70bc9c68 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:27:29 +1000 Subject: [PATCH 093/155] Remove dynamic rate limits from Tesla Fleet (#138576) * remove * TEsts --- .../components/tesla_fleet/coordinator.py | 22 +++++-------------- tests/components/tesla_fleet/test_init.py | 18 ++++++--------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 129f460ff90..128c15068f6 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -17,7 +17,6 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) -from tesla_fleet_api.ratecalculator import RateCalculator from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -66,7 +65,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): updated_once: bool pre2021: bool last_active: datetime - rate: RateCalculator def __init__( self, @@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" try: - # Check if the vehicle is awake using a non-rate limited API call - if self.data["state"] != TeslaFleetState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] + # Check if the vehicle is awake using a free API call + response = await self.api.vehicle() + self.data["state"] = response["response"]["state"] if self.data["state"] != TeslaFleetState.ONLINE: return self.data - # This is a rated limited API call - self.rate.consume() response = await self.api.vehicle_data(endpoints=ENDPOINTS) data = response["response"] except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited as e: + except RateLimited: LOGGER.warning( - "%s rate limited, will retry in %s seconds", + "%s rate limited, will skip refresh", self.name, - e.data.get("after"), ) - if "after" in e.data: - self.update_interval = timedelta(seconds=int(e.data["after"])) return self.data except (InvalidToken, OAuthExpired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - # Calculate ideal refresh interval - self.update_interval = timedelta(seconds=self.rate.calculate()) + self.update_interval = VEHICLE_INTERVAL self.updated_once = True diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 2162226efb0..ff103ce03c2 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -156,14 +156,15 @@ async def test_vehicle_refresh_offline( mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() - # Then the vehicle goes offline + # Then the vehicle goes offline despite saying its online mock_vehicle_data.side_effect = VehicleOffline freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - mock_vehicle_state.assert_not_called() + mock_vehicle_state.assert_called_once() mock_vehicle_data.assert_called_once() + mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() # And stays offline @@ -212,20 +213,15 @@ async def test_vehicle_refresh_ratelimited( assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - assert mock_vehicle_data.call_count == 1 + + mock_vehicle_data.reset_mock() freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # Should not call for another 10 seconds - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "unknown" async def test_vehicle_sleep( From 05696b5528f2d42e4dbdd696a07081eac2509675 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 15 Feb 2025 16:28:10 +0100 Subject: [PATCH 094/155] Add Event entity states to diagnostics for Bang & Olufsen (#135859) Add diagnostics for event buttons --- .../components/bang_olufsen/diagnostics.py | 18 ++++++++++++++++-- .../snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ .../bang_olufsen/test_diagnostics.py | 8 ++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index bf7b06e694a..3835de7c551 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry -from .const import DOMAIN +from .const import DEVICE_BUTTONS, DOMAIN async def async_get_config_entry_diagnostics( @@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics( if TYPE_CHECKING: assert config_entry.unique_id - # Add media_player entity's state entity_registry = er.async_get(hass) + + # Add media_player entity's state if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id ): @@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context") data["media_player"] = state_dict + # Add button Event entity states (if enabled) + for device_button in DEVICE_BUTTONS: + if entity_id := entity_registry.async_get_entity_id( + EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}" + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data[f"{device_button}_event"] = state_dict + return data diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index d7f9a045921..bc51f89f96d 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -1,6 +1,22 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + 'PlayPause_event': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'short_press_release', + 'long_press_timeout', + 'long_press_release', + 'very_long_press_timeout', + 'very_long_press_release', + ]), + 'friendly_name': 'Living room Balance Play / Pause', + }), + 'entity_id': 'event.beosound_balance_11111111_play_pause', + 'state': 'unknown', + }), 'config_entry': dict({ 'data': dict({ 'host': '192.168.0.1', diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index 7c99648ace4..a9415a222a8 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -6,6 +6,9 @@ from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,6 +17,7 @@ from tests.typing import ClientSessionGenerator async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + entity_registry: EntityRegistry, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, @@ -23,6 +27,10 @@ async def test_async_get_config_entry_diagnostics( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + # Enable an Event entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) From 482df7408a047954a559996029f8ec768a160cd9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:29:09 +0100 Subject: [PATCH 095/155] Provide part of uuid when requesting token for HomeWizard v2 API (#138586) --- homeassistant/components/homewizard/config_flow.py | 14 ++++++++++---- homeassistant/components/homewizard/repairs.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 6bcc51f939e..68dc54aef0e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -23,8 +23,10 @@ import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import instance_id from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) errors: dict[str, str] | None = None if token is None: @@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) if user_input is not None: if token is None: @@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device await energy_api.close() -async def async_request_token(ip_address: str) -> str | None: +async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None: """Try to request a token from the device. This method is used to request a token from the device, @@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None: api = HomeWizardEnergyV2(ip_address) + # Get a part of the unique id to make the token unique + # This is to prevent token conflicts when multiple HA instances are used + uuid = await instance_id.async_get(hass) + try: - return await api.get_token("home-assistant") + return await api.get_token(f"home-assistant#{uuid[:6]}") except DisabledError: return None finally: diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py index 4c9a03b493f..60790202032 100644 --- a/homeassistant/components/homewizard/repairs.py +++ b/homeassistant/components/homewizard/repairs.py @@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(ip_address) + token = await async_request_token(self.hass, ip_address) errors: dict[str, str] | None = None if token is None: From 78c4d815cea9ff532c9de71cf9bec163d4aad0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 20:10:27 +0100 Subject: [PATCH 096/155] Fix home connect coffe-milk ratio option (#138593) * Fix home connect milk ratio option * Use enumeration instead of number selector for coffee-milk ratio --- .../components/home_connect/__init__.py | 1 - .../components/home_connect/const.py | 25 ++++++++++++++++++ .../components/home_connect/services.yaml | 26 ++++++++++++++----- .../components/home_connect/strings.json | 19 ++++++++++++++ .../home_connect/snapshots/test_init.ambr | 2 +- tests/components/home_connect/test_init.py | 2 +- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 59a33f01bcb..01eb6e8fbea 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -76,7 +76,6 @@ PROGRAM_OPTIONS = { for key, value in { OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 0ec7d3a2629..3a22297ebee 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -157,6 +157,27 @@ FLOW_RATE_OPTIONS = { ) } +COFFEE_MILK_RATIO_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent", + ) +} + HOT_WATER_TEMPERATURE_OPTIONS = { bsh_key_to_translation_key(option): option for option in ( @@ -300,6 +321,10 @@ PROGRAM_ENUM_OPTIONS = { BEAN_CONTAINER_OPTIONS, ), (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + COFFEE_MILK_RATIO_OPTIONS, + ), ( OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, HOT_WATER_TEMPERATURE_OPTIONS, diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 29ca3da15fc..50e50afd598 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -328,14 +328,28 @@ set_program_and_options: selector: boolean: consumer_products_coffee_maker_option_coffee_milk_ratio: - example: 50 + example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent required: false selector: - number: - unit_of_measurement: "%" - step: 10 - min: 10 - max: 90 + select: + mode: dropdown + translation_key: coffee_milk_ratio + options: + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent consumer_products_coffee_maker_option_hot_water_temperature: example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c required: false diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8bee37796ad..3ffd84e61b2 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -344,6 +344,25 @@ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" } }, + "coffee_milk_ratio": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "10%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "20%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "25%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "30%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "40%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "50%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "55%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "60%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "65%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "67%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "70%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "75%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "80%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "85%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "90%" + } + }, "hot_water_temperature": { "options": { "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr index 581eca66cb8..709621aaefb 100644 --- a/tests/components/home_connect/snapshots/test_init.ambr +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -50,7 +50,7 @@ 'key': , 'name': None, 'unit': None, - 'value': 60, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent', }), ]), }), diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e7380d0e255..9e514824147 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -173,7 +173,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "service_data": { "device_id": "DEVICE_ID", "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", }, "blocking": True, }, From 78337a6846eaeb042d80b89150dd3827943d921e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 15 Feb 2025 20:16:07 +0100 Subject: [PATCH 097/155] Disable zwave_js testing resetting the controller (#138595) * Improve zwave_js test of resetting the controller * Disable the test --- tests/components/zwave_js/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index a3f70e92dcf..6f341f8f77b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4930,6 +4930,9 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.skip( + reason="The test needs to be updated to reflect what happens when resetting the controller" +) async def test_hard_reset_controller( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 0a78f2725d2ad99d9280cc8bb0308281768ece43 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 12:20:33 -0700 Subject: [PATCH 098/155] Add switch to toggle filter cycle 2 on balboa spas (#138605) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 5 ++ homeassistant/components/balboa/switch.py | 48 ++++++++++++++++ tests/components/balboa/conftest.py | 1 + .../balboa/snapshots/test_switch.ambr | 48 ++++++++++++++++ tests/components/balboa/test_switch.py | 55 +++++++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 homeassistant/components/balboa/switch.py create mode 100644 tests/components/balboa/snapshots/test_switch.ambr create mode 100644 tests/components/balboa/test_switch.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 78bf6f7cda7..207826d136e 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.SWITCH, Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 0262f26f4bd..9779984b182 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -79,6 +79,11 @@ } } }, + "switch": { + "filter_cycle_2_enabled": { + "name": "Filter cycle 2 enabled" + } + }, "time": { "filter_cycle_start": { "name": "Filter cycle {index} start" diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py new file mode 100644 index 00000000000..c8c947f499d --- /dev/null +++ b/homeassistant/components/balboa/switch.py @@ -0,0 +1,48 @@ +"""Support for Balboa switches.""" + +from __future__ import annotations + +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's switches.""" + spa = entry.runtime_data + async_add_entities([BalboaSwitchEntity(spa)]) + + +class BalboaSwitchEntity(BalboaEntity, SwitchEntity): + """Representation of a Balboa switch entity.""" + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa switch entity.""" + super().__init__(spa, "filter_cycle_2_enabled") + self._attr_entity_category = EntityCategory.CONFIG + self._attr_translation_key = "filter_cycle_2_enabled" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._client.filter_cycle_2_enabled + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._client.configure_filter_cycle(2, enabled=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._client.configure_filter_cycle(2, enabled=False) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 3a3561f15cf..90f8fdc3d6e 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -52,6 +52,7 @@ def client_fixture() -> Generator[MagicMock]: client.filter_cycle_1_start = time(8, 0) client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_enabled = True client.filter_cycle_2_start = time(19, 0) client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ad63fcdf387 --- /dev/null +++ b/tests/components/balboa/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 enabled', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_2_enabled', + 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 enabled', + }), + 'context': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py new file mode 100644 index 00000000000..4b6bae172f4 --- /dev/null +++ b/tests/components/balboa/test_switch.py @@ -0,0 +1,55 @@ +"""Tests of the switches of the balboa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform +from tests.components.switch import common + +ENTITY_SWITCH = "switch.fakespa_filter_cycle_2_enabled" + + +async def test_switches( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa switches.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SWITCH]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_switch(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa filter cycle enabled switch.""" + await init_integration(hass) + + # check if the initial state is on + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_ON + + # test calling turn off + await common.async_turn_off(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=False) + + setattr(client, "filter_cycle_2_enabled", False) + client.emit("") + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_OFF + + # test calling turn on + await common.async_turn_on(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=True) From 827865a1b9a7fbdf7315fa60fb81a2e8f3877b5a Mon Sep 17 00:00:00 2001 From: CodingSquirrel <13072675+CodingSquirrel@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:36:54 -0500 Subject: [PATCH 099/155] Bump pyeconet to 0.1.28 (#138610) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 86e3b3527f0..bc7505740d7 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.27"] + "requirements": ["pyeconet==0.1.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index d48ed91eaae..236eb447c8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1912,7 +1912,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c9f65ab481..a87d322f6a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1559,7 +1559,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 From 6059446ae362311878b7663fcb53329f77d401c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 15 Feb 2025 21:39:06 +0100 Subject: [PATCH 100/155] Bump plugwise to v1.7.2 (#138613) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 983ff10b0a6..87878980f2d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.1"], + "requirements": ["plugwise==1.7.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 236eb447c8f..b7081812b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a87d322f6a1..4c995a6bead 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From fdaa640c8ec41589eacf292bfd7335bbdd12d61e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 15 Feb 2025 21:44:59 +0100 Subject: [PATCH 101/155] Add issues for data cap to onedrive (#138411) * Add issues for data cap to onedrive * brackets * Fix double space Co-authored-by: Daniel O'Connor --------- Co-authored-by: Daniel O'Connor --- .../components/onedrive/coordinator.py | 25 ++++++++++++ .../components/onedrive/strings.json | 10 +++++ tests/components/onedrive/test_init.py | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index cc759437c07..7b2dbaab87a 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -8,12 +8,14 @@ from datetime import timedelta import logging from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException from onedrive_personal_sdk.models.items import Drive from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -67,4 +69,27 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": str(drive.quota.total), + "used": str(drive.quota.used), + }, + ) return drive diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index c3087d435b8..3a9f6d06594 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -29,6 +29,16 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "issues": { + "drive_full": { + "title": "OneDrive data cap exceeded", + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + }, + "drive_almost_full": { + "title": "OneDrive near data cap", + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + } + }, "exceptions": { "authentication_failed": { "message": "Authentication failed" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 65c3e62629c..b4ec138ebf4 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,9 +1,11 @@ """Test the OneDrive setup.""" +from copy import deepcopy from html import escape from json import dumps from unittest.mock import MagicMock +from onedrive_personal_sdk.const import DriveState from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest from syrupy import SnapshotAssertion @@ -11,7 +13,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.onedrive.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE @@ -131,3 +133,38 @@ async def test_device( device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ( + "drive_state", + "issue_key", + "issue_exists", + ), + [ + (DriveState.NORMAL, "drive_full", False), + (DriveState.NORMAL, "drive_almost_full", False), + (DriveState.CRITICAL, "drive_almost_full", True), + (DriveState.CRITICAL, "drive_full", False), + (DriveState.EXCEEDED, "drive_almost_full", False), + (DriveState.EXCEEDED, "drive_full", True), + ], +) +async def test_data_cap_issues( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + drive_state: DriveState, + issue_key: str, + issue_exists: bool, +) -> None: + """Make sure we get issues for high data usage.""" + mock_drive = deepcopy(MOCK_DRIVE) + assert mock_drive.quota + mock_drive.quota.state = drive_state + mock_onedrive_client.get_drive.return_value = mock_drive + await setup_integration(hass, mock_config_entry) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, issue_key) + assert (issue is not None) == issue_exists From a3eb73cfcc8ada5175a042f12e647b7bf8f50124 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:46:00 +0100 Subject: [PATCH 102/155] Replace alarm action descriptions with wording from online docs (#138608) --- .../components/alarm_control_panel/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5f718280566..ed02b2d0ee8 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -90,7 +90,7 @@ }, "alarm_arm_home": { "name": "Arm home", - "description": "Sets the alarm to: _armed, but someone is home_.", + "description": "Arms the alarm in the home mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -100,7 +100,7 @@ }, "alarm_arm_away": { "name": "Arm away", - "description": "Sets the alarm to: _armed, no one home_.", + "description": "Arms the alarm in the away mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -110,7 +110,7 @@ }, "alarm_arm_night": { "name": "Arm night", - "description": "Sets the alarm to: _armed for the night_.", + "description": "Arms the alarm in the night mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -120,7 +120,7 @@ }, "alarm_arm_vacation": { "name": "Arm vacation", - "description": "Sets the alarm to: _armed for vacation_.", + "description": "Arms the alarm in the vacation mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -130,7 +130,7 @@ }, "alarm_trigger": { "name": "Trigger", - "description": "Trigger the alarm manually.", + "description": "Triggers the alarm manually.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", From d435f7be0924c12685d3030829c61cf160f986e5 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:50:52 -0500 Subject: [PATCH 103/155] Update integrations screenshot in README (#138555) --- .github/assets/screenshot-integrations.png | Bin 66219 -> 101607 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png index 8d71bf538d6c775dc4c09447b265497d8cf83a47..abbc0f76ff0288adc024528eb3ae0ef2bae41156 100644 GIT binary patch literal 101607 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 z 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
  • 8Ml((_4P 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 literal 66219 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%0Db7Ld=J9 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 ZLi%oAV{P28j%dI~Szbe~Le@O^e*tKtWA6X} From 9573f7828baa4b969796625a2869cec3ba840dc6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:52:41 +0100 Subject: [PATCH 104/155] Update action description in ecovacs integration to match HA style (#138548) --- homeassistant/components/ecovacs/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 723bdef17f8..44c51c7ae43 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -250,7 +250,7 @@ "message": "Params are required for the command: {command}" }, "vacuum_raw_get_positions_not_supported": { - "message": "Getting the positions of the chargers and the device itself is not supported" + "message": "Retrieving the positions of the chargers and the device itself is not supported" } }, "selector": { @@ -264,7 +264,7 @@ "services": { "raw_get_positions": { "name": "Get raw positions", - "description": "Get the raw response for the positions of the chargers and the device itself." + "description": "Retrieves a raw response containing the positions of the chargers and the device itself." } } } From c75707ec79f50e8cc24a06c61b43b9ee610eab3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 16 Feb 2025 00:29:38 +0100 Subject: [PATCH 105/155] Use correct inputs for relative time and duration options (#138619) --- .../components/home_connect/__init__.py | 33 ++++--------------- .../components/home_connect/services.yaml | 24 ++++++++++---- tests/components/home_connect/test_init.py | 2 +- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01eb6e8fbea..a020b2370b9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable -from datetime import timedelta import logging from typing import Any, cast @@ -74,6 +73,9 @@ PROGRAM_OPTIONS = { value, ) for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, @@ -92,18 +94,6 @@ PROGRAM_OPTIONS = { }.items() } -TIME_PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, - OptionKey.BSH_COMMON_DURATION: cv.time_period_str, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, - }.items() -} - SERVICE_SETTING_SCHEMA = vol.Schema( { @@ -156,10 +146,7 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( def _require_program_or_at_least_one_option(data: dict) -> dict: if ATTR_PROGRAM not in data and not any( - option_key in data - for option_key in ( - PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ) + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) ): raise ServiceValidationError( translation_domain=DOMAIN, @@ -190,9 +177,7 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( .extend( { vol.Optional(translation_key): schema - for translation_key, (key, schema) in ( - PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ).items() + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() } ), _require_program_or_at_least_one_option, @@ -486,13 +471,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif option in PROGRAM_OPTIONS: option_key = PROGRAM_OPTIONS[option][0] options.append(Option(option_key, value)) - elif option in TIME_PROGRAM_OPTIONS: - options.append( - Option( - TIME_PROGRAM_OPTIONS[option][0], - int(cast(timedelta, value).total_seconds()), - ) - ) + method_call: Awaitable[Any] exception_translation_key: str if program: diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 50e50afd598..91b0089d653 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -387,10 +387,14 @@ set_program_and_options: collapsed: true fields: b_s_h_common_option_start_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s dishcare_dishwasher_option_intensiv_zone: example: false required: false @@ -493,10 +497,14 @@ set_program_and_options: mode: box unit_of_measurement: °C/°F b_s_h_common_option_duration: - example: "30:00" + example: 900 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s cooking_oven_option_fast_pre_heat: example: false required: false @@ -561,10 +569,14 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_ul_medium - laundry_care_washer_enum_type_spin_speed_ul_high b_s_h_common_option_finish_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s laundry_care_washer_option_i_dos1_active: example: false required: false diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 9e514824147..5e309a7446e 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -152,7 +152,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "device_id": "DEVICE_ID", "affects_to": "selected_program", "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": "00:30:00", + "b_s_h_common_option_start_in_relative": 1800, }, "blocking": True, }, From 21032ea7cd0ce7905a22f1eb0a5377233fcdba7b Mon Sep 17 00:00:00 2001 From: Teynar <97400690+teynar@users.noreply.github.com> Date: Sun, 16 Feb 2025 10:21:34 +0100 Subject: [PATCH 106/155] Add missing unit for Withings snore sensor (#138517) --- homeassistant/components/withings/sensor.py | 3 +++ tests/components/withings/snapshots/test_sensor.ambr | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 96cb433deba..28a0fbd1492 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -425,6 +425,9 @@ SLEEP_SENSORS = [ key="sleep_snoring", value_fn=lambda sleep_summary: sleep_summary.snoring, translation_key="snoring", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 543cba05e21..ec9fc1ed3fc 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -3198,8 +3198,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Snoring', 'platform': 'withings', @@ -3207,21 +3210,23 @@ 'supported_features': 0, 'translation_key': 'snoring', 'unique_id': 'withings_12345_sleep_snoring', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_snoring-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'henk Snoring', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1080', + 'state': '18.0', }) # --- # name: test_all_entities[sensor.henk_snoring_episode_count-entry] From 3ce8e1683aac5f721e7720aa2b90f080abb1d630 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 12:17:21 +0100 Subject: [PATCH 107/155] Fix sentence-casing in ZHA integration, capitalize names (#138636) * Fix sentence-casing in ZHA integration, capitalize names * Reorder title and description keys * Remove wrong trailing commas * Restore accidental deletion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/zha/strings.json | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index c73a0989faa..2007adca0da 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name}", "step": { "choose_serial_port": { - "title": "Select a Serial Port", + "title": "Select a serial port", + "description": "Select the serial port for your Zigbee radio", "data": { - "path": "Serial Device Path" - }, - "description": "Select the serial port for your Zigbee radio" + "path": "Serial device path" + } }, "confirm": { "description": "Do you want to set up {name}?" @@ -16,14 +16,14 @@ "description": "Do you want to set up {name}?" }, "manual_pick_radio_type": { + "title": "Select a radio type", + "description": "Pick your Zigbee radio type", "data": { - "radio_type": "Radio Type" - }, - "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", - "description": "Pick your Zigbee radio type" + "radio_type": "Radio type" + } }, "manual_port_config": { - "title": "Serial Port Settings", + "title": "Serial port settings", "description": "Enter the serial port settings", "data": { "path": "Serial device path", @@ -36,7 +36,7 @@ "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." }, "choose_formation_strategy": { - "title": "Network Formation", + "title": "Network formation", "description": "Choose the network settings for your radio.", "menu_options": { "form_new_network": "Erase network settings and create a new network", @@ -47,21 +47,21 @@ } }, "choose_automatic_backup": { - "title": "Restore Automatic Backup", + "title": "Restore automatic backup", "description": "Restore your network settings from an automatic backup", "data": { "choose_automatic_backup": "Choose an automatic backup" } }, "upload_manual_backup": { - "title": "Upload a Manual Backup", + "title": "Upload a manual backup", "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "data": { "uploaded_backup_file": "Upload a file" } }, "maybe_confirm_ezsp_restore": { - "title": "Overwrite Radio IEEE Address", + "title": "Overwrite radio IEEE address", "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "data": { "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address" @@ -74,10 +74,10 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device", - "usb_probe_failed": "Failed to probe the usb device", + "not_zha_device": "This device is not a ZHA device", + "usb_probe_failed": "Failed to probe the USB device", "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", - "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA" + "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA" } }, "options": { @@ -307,7 +307,7 @@ } }, "set_zigbee_cluster_attribute": { - "name": "Set zigbee cluster attribute", + "name": "Set Zigbee cluster attribute", "description": "Sets an attribute value for the specified cluster on the specified entity.", "fields": { "ieee": { @@ -323,7 +323,7 @@ "description": "ZCL cluster to retrieve attributes for." }, "cluster_type": { - "name": "Cluster Type", + "name": "Cluster type", "description": "Type of the cluster." }, "attribute": { @@ -341,7 +341,7 @@ } }, "issue_zigbee_cluster_command": { - "name": "Issue zigbee cluster command", + "name": "Issue Zigbee cluster command", "description": "Issues a command on the specified cluster on the specified entity.", "fields": { "ieee": { @@ -383,8 +383,8 @@ } }, "issue_zigbee_group_command": { - "name": "Issue zigbee group command", - "description": "Issue command on the specified cluster on the specified group.", + "name": "Issue Zigbee group command", + "description": "Issues a command on the specified cluster on the specified group.", "fields": { "group": { "name": "Group", From 95b1cf465b4cfe34a3395d35184dbb8c20117841 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 16 Feb 2025 13:08:01 +0100 Subject: [PATCH 108/155] Use gibibytes for onedrive (#138637) * Use gibibytes for onedrive * also to strings --- homeassistant/components/onedrive/sensor.py | 6 +++--- .../components/onedrive/strings.json | 4 ++-- tests/components/onedrive/const.py | 4 ++-- .../onedrive/snapshots/test_sensor.ambr | 20 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 35c59d0c644..0ca2b166e3f 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -36,7 +36,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="total_size", value_fn=lambda quota: quota.total, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=0, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -46,7 +46,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="used_size", value_fn=lambda quota: quota.used, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -55,7 +55,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="remaining_size", value_fn=lambda quota: quota.remaining, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 3a9f6d06594..20d139a4bc0 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -32,11 +32,11 @@ "issues": { "drive_full": { "title": "OneDrive data cap exceeded", - "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." }, "drive_almost_full": { "title": "OneDrive near data cap", - "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." } }, "exceptions": { diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 44f50aa625d..0c04a6f4c82 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -110,9 +110,9 @@ MOCK_DRIVE = Drive( owner=IDENTITY_SET, quota=DriveQuota( deleted=5, - remaining=750000000, + remaining=805306368, state=DriveState.NEARING, - total=5000000000, + total=5368709120, used=4250000000, ), ) diff --git a/tests/components/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr index 43c6921b0e5..742c069f206 100644 --- a/tests/components/onedrive/snapshots/test_sensor.ambr +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -86,7 +86,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -97,7 +97,7 @@ 'supported_features': 0, 'translation_key': 'remaining_size', 'unique_id': 'mock_drive_id_remaining_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_remaining_storage-state] @@ -105,7 +105,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Remaining storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_remaining_storage', @@ -141,7 +141,7 @@ 'suggested_display_precision': 0, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -152,7 +152,7 @@ 'supported_features': 0, 'translation_key': 'total_size', 'unique_id': 'mock_drive_id_total_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_total_available_storage-state] @@ -160,7 +160,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Total available storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_total_available_storage', @@ -196,7 +196,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -207,7 +207,7 @@ 'supported_features': 0, 'translation_key': 'used_size', 'unique_id': 'mock_drive_id_used_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_used_storage-state] @@ -215,13 +215,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Used storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_used_storage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.25', + 'state': '3.95812094211578', }) # --- From 7f3270e982a80b2fd42a26835a827d31206872f8 Mon Sep 17 00:00:00 2001 From: Luca Bensi <130408125+lucab-91@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:09:15 +0100 Subject: [PATCH 109/155] Bump pysmarty2 to 0.10.2 (#138625) --- homeassistant/components/smarty/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index ca3133d8add..c295647b8e5 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty2"], - "requirements": ["pysmarty2==0.10.1"] + "requirements": ["pysmarty2==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7081812b44..de28799abdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.smhi pysmhi==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c995a6bead..4e8544fcfbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.smhi pysmhi==1.0.0 From e767863ea4229ca53d23f0378ad2e29aa153ba37 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 13:17:47 +0100 Subject: [PATCH 110/155] Replace opentherm_gw action key name with friendly name for UI (#138634) --- homeassistant/components/opentherm_gw/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 405af126c03..b49dea4a267 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -385,7 +385,7 @@ }, "set_central_heating_ovrd": { "name": "Set central heating override", - "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -393,7 +393,7 @@ }, "ch_override": { "name": "Central heating override", - "description": "The desired boolean value for the central heating override." + "description": "Whether to enable or disable the override." } } }, From 9e15a33c42d867b8a48c57f803aece45ac7c3384 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 14:46:08 +0100 Subject: [PATCH 111/155] Fix sentence-casing and capitalization of "Zigbee" in smlight (#138647) --- homeassistant/components/smlight/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 21ff5098d27..ca52f6fea38 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Set up SMLIGHT Zigbee Integration", + "description": "Set up SMLIGHT Zigbee integration", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -111,7 +111,7 @@ "name": "Zigbee flash mode" }, "reconnect_zigbee_router": { - "name": "Reconnect zigbee router" + "name": "Reconnect Zigbee router" } }, "switch": { From 2d5e920de0e3dc52be5e072ea7679665aebfc46d Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 16 Feb 2025 14:55:05 +0100 Subject: [PATCH 112/155] Flexit bacnet/quality preparations (#138514) Add data_description for config flow --- homeassistant/components/flexit_bacnet/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index f7c54c88050..488d93fbd61 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -5,6 +5,10 @@ "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "device_id": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "ip_address": "The IP address of the Flexit Nordic device", + "device_id": "The device ID of the Flexit Nordic device" } } }, From f67fb9985e378467cc5d8aac07ebd636c1e76bda Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 16 Feb 2025 15:12:16 +0100 Subject: [PATCH 113/155] Allow wifi switches for mesh repeaters in AVM Fritz!Box Tools (#135456) * create wifi switches for mesh slaves, but disable them by default * check if mesh isbased on wifi uplink * fix --- homeassistant/components/fritz/coordinator.py | 7 +++++++ homeassistant/components/fritz/switch.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 38d76c92871..d60232ec8ad 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -196,6 +196,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.hass = hass self.host = host self.mesh_role = MeshRoles.NONE + self.mesh_wifi_uplink = False self.device_conn_type: str | None = None self.device_is_router: bool = False self.password = password @@ -610,6 +611,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ssid=interf.get("ssid", ""), type=interf["type"], ) + + if interf["type"].lower() == "wlan" and interf[ + "name" + ].lower().startswith("uplink"): + self.mesh_wifi_uplink = True + if dr.format_mac(int_mac) == self.mac: self.mesh_role = MeshRoles(node["mesh_role"]) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 1548f8fc755..8b4816f7451 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -207,8 +207,9 @@ async def async_all_entities_list( local_ip: str, ) -> list[Entity]: """Get a list of all entities.""" - if avm_wrapper.mesh_role == MeshRoles.SLAVE: + if not avm_wrapper.mesh_wifi_uplink: + return [*await _async_wifi_entities_list(avm_wrapper, device_friendly_name)] return [] return [ @@ -565,6 +566,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): self._attributes = {} self._attr_entity_category = EntityCategory.CONFIG + self._attr_entity_registry_enabled_default = ( + avm_wrapper.mesh_role is not MeshRoles.SLAVE + ) self._network_num = network_num switch_info = SwitchInfo( From 7063636db6a03c8b86b95215a3daf11470bce56a Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 16 Feb 2025 17:06:09 +0100 Subject: [PATCH 114/155] Add quality scale bronze for flexit_bacnet (#138309) * Add quality scale bronze for flexit_bacnet * Add new line at end of file * Remove flexit_bacnet from list of integrations without quality scale * Add missing translation strings * Fix review comments * Remove flexit_bacnet from list of integrations without quality scale * Review comment Co-authored-by: Josef Zweck * Review comment Co-authored-by: Josef Zweck * Add the complete list of quality scale rules * Fix lint error * Use correct formatting for todos * Fix lint error * Set all rules above bronze to todo * Update status for rules that are done * Update homeassistant/components/flexit_bacnet/quality_scale.yaml * Update homeassistant/components/flexit_bacnet/quality_scale.yaml --------- Co-authored-by: Josef Zweck --- .../components/flexit_bacnet/manifest.json | 1 + .../flexit_bacnet/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/flexit_bacnet/quality_scale.yaml diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json index 6f6b094c950..5ef3f11a7b7 100644 --- a/homeassistant/components/flexit_bacnet/manifest.json +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "bronze", "requirements": ["flexit_bacnet==2.2.3"] } diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml new file mode 100644 index 00000000000..9b7e4deb4c0 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Integration does not define custom 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: | + This integration does not use any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities don't subscribe to events explicitly + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Done implicitly with `await coordinator.async_config_entry_first_refresh()`. + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + Integration does not use options flow. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: | + Done implicitly with coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: | + Done implicitly with coordinator. + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: todo + stale-devices: + status: exempt + comment: | + Device type integration. + diagnostics: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: | + Device type integration. + discovery-update-info: todo + repair-issues: + status: exempt + comment: | + This is not applicable for this integration. + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: done + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 12b5932695d..bd8a5a9f318 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -391,7 +391,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", @@ -1455,7 +1454,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", From e0b50ee1e21e5d8b1986519a4104b6d9a3ad3c7a Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Sun, 16 Feb 2025 13:04:45 -0500 Subject: [PATCH 115/155] Bump sense_energy to 0.13.5 (#138659) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index da3912a9d25..384dd3556a9 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.4"] + "requirements": ["sense-energy==0.13.5"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 966488b6a48..a7cee28f9c9 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.4"] + "requirements": ["sense-energy==0.13.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index de28799abdd..abbda498827 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e8544fcfbb..f6223d56c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2167,7 +2167,7 @@ securetar==2025.1.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From ccd0e27e84bf66c83e76685125e54122eea9fdbb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 16 Feb 2025 20:00:17 +0100 Subject: [PATCH 116/155] Allow renaming of backup files in Synology DSM (#138652) * get backup base file name from meta file * use BackupNotFound --- .../components/synology_dsm/backup.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 83c3455bdf1..670c4c9bef0 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -14,6 +14,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentError, + BackupNotFound, suggested_filename, ) from homeassistant.config_entries import ConfigEntry @@ -101,6 +102,7 @@ class SynologyDSMBackupAgent(BackupAgent): ) syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] self.api = syno_data.api + self.backup_base_names: dict[str, str] = {} @property def _file_station(self) -> SynoFileStation: @@ -109,18 +111,19 @@ class SynologyDSMBackupAgent(BackupAgent): assert self.api.file_station return self.api.file_station - async def _async_suggested_filenames( + async def _async_backup_filenames( self, backup_id: str, ) -> tuple[str, str]: - """Suggest filenames for the backup. + """Return the actual backup filenames. :param backup_id: The ID of the backup that was returned in async_list_backups. :return: A tuple of tar_filename and meta_filename """ - if (backup := await self.async_get_backup(backup_id)) is None: - raise BackupAgentError("Backup not found") - return suggested_filenames(backup) + if await self.async_get_backup(backup_id) is None: + raise BackupNotFound + base_name = self.backup_base_names[backup_id] + return (f"{base_name}.tar", f"{base_name}_meta.json") async def async_download_backup( self, @@ -132,7 +135,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - (filename_tar, _) = await self._async_suggested_filenames(backup_id) + (filename_tar, _) = await self._async_backup_filenames(backup_id) try: resp = await self._file_station.download_file( @@ -193,7 +196,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ try: - (filename_tar, filename_meta) = await self._async_suggested_filenames( + (filename_tar, filename_meta) = await self._async_backup_filenames( backup_id ) except BackupAgentError: @@ -247,6 +250,7 @@ class SynologyDSMBackupAgent(BackupAgent): assert files backups: dict[str, AgentBackup] = {} + backup_base_names: dict[str, str] = {} for file in files: if file.name.endswith("_meta.json"): try: @@ -255,7 +259,10 @@ class SynologyDSMBackupAgent(BackupAgent): LOGGER.error("Failed to download meta data: %s", err) continue agent_backup = AgentBackup.from_dict(meta_data) - backups[agent_backup.backup_id] = agent_backup + backup_id = agent_backup.backup_id + backups[backup_id] = agent_backup + backup_base_names[backup_id] = file.name.replace("_meta.json", "") + self.backup_base_names = backup_base_names return backups async def async_get_backup( From 0b7ec9644889a88d4f27bc342dc3681269202ee4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 16 Feb 2025 21:17:26 +0100 Subject: [PATCH 117/155] Improve remember the milk storage (#138618) --- .../components/remember_the_milk/__init__.py | 67 +++++--- tests/components/remember_the_milk/const.py | 2 +- .../components/remember_the_milk/test_init.py | 155 ++++++++++++------ 3 files changed, 148 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 0d1c54efb56..2a95ed46b20 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -2,7 +2,7 @@ import json import logging -import os +from pathlib import Path from rtmapi import Rtm import voluptuous as vol @@ -160,56 +160,64 @@ class RememberTheMilkConfiguration: This class stores the authentication token it get from the backend. """ - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Create new instance of configuration.""" self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - if not os.path.isfile(self._config_file_path): - self._config = {} - return + self._config = {} + _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) try: - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - with open(self._config_file_path, encoding="utf8") as config_file: - self._config = json.load(config_file) - except ValueError: - _LOGGER.error( - "Failed to load configuration file, creating a new one: %s", + self._config = json.loads( + Path(self._config_file_path).read_text(encoding="utf8") + ) + except FileNotFoundError: + _LOGGER.debug("Missing configuration file: %s", self._config_file_path) + except OSError: + _LOGGER.debug( + "Failed to read from configuration file, %s, using empty configuration", + self._config_file_path, + ) + except ValueError: + _LOGGER.error( + "Failed to parse configuration file, %s, using empty configuration", self._config_file_path, ) - self._config = {} - def save_config(self): + def _save_config(self) -> None: """Write the configuration to a file.""" - with open(self._config_file_path, "w", encoding="utf8") as config_file: - json.dump(self._config, config_file) + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) - def get_token(self, profile_name): + def get_token(self, profile_name: str) -> str | None: """Get the server token for a profile.""" if profile_name in self._config: return self._config[profile_name][CONF_TOKEN] return None - def set_token(self, profile_name, token): + def set_token(self, profile_name: str, token: str) -> None: """Store a new server token for a profile.""" self._initialize_profile(profile_name) self._config[profile_name][CONF_TOKEN] = token - self.save_config() + self._save_config() - def delete_token(self, profile_name): + def delete_token(self, profile_name: str) -> None: """Delete a token for a profile. Usually called when the token has expired. """ self._config.pop(profile_name, None) - self.save_config() + self._save_config() - def _initialize_profile(self, profile_name): + def _initialize_profile(self, profile_name: str) -> None: """Initialize the data structures for a profile.""" if profile_name not in self._config: self._config[profile_name] = {} if CONF_ID_MAP not in self._config[profile_name]: self._config[profile_name][CONF_ID_MAP] = {} - def get_rtm_id(self, profile_name, hass_id): + def get_rtm_id( + self, profile_name: str, hass_id: str + ) -> tuple[str, str, str] | None: """Get the RTM ids for a Home Assistant task ID. The id of a RTM tasks consists of the tuple: @@ -221,7 +229,14 @@ class RememberTheMilkConfiguration: return None return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] - def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_task_id): + def set_rtm_id( + self, + profile_name: str, + hass_id: str, + list_id: str, + time_series_id: str, + rtm_task_id: str, + ) -> None: """Add/Update the RTM task ID for a Home Assistant task IS.""" self._initialize_profile(profile_name) id_tuple = { @@ -230,11 +245,11 @@ class RememberTheMilkConfiguration: CONF_TASK_ID: rtm_task_id, } self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self.save_config() + self._save_config() - def delete_rtm_id(self, profile_name, hass_id): + def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: """Delete a key mapping.""" self._initialize_profile(profile_name) if hass_id in self._config[profile_name][CONF_ID_MAP]: del self._config[profile_name][CONF_ID_MAP][hass_id] - self.save_config() + self._save_config() diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 8423c7f4651..3f1d0067219 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -8,7 +8,7 @@ JSON_STRING = json.dumps( { "myprofile": { "token": "mytoken", - "id_map": {"1234": {"list_id": "0", "timeseries_id": "1", "task_id": "2"}}, + "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, } } ) diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index 3ada2d343fe..517c8cebc0e 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -1,6 +1,9 @@ -"""Tests for the Remember The Milk component.""" +"""Tests for the Remember The Milk integration.""" -from unittest.mock import Mock, mock_open, patch +import json +from unittest.mock import mock_open, patch + +import pytest from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant @@ -8,63 +11,117 @@ from homeassistant.core import HomeAssistant from .const import JSON_STRING, PROFILE, TOKEN -def test_create_new(hass: HomeAssistant) -> None: - """Test creating a new config file.""" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), - ): +def test_set_get_delete_token(hass: HomeAssistant) -> None: + """Test set, get and delete token.""" + open_mock = mock_open() + with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): config = rtm.RememberTheMilkConfiguration(hass) + assert open_mock.return_value.write.call_count == 0 + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 0 config.set_token(PROFILE, TOKEN) - assert config.get_token(PROFILE) == TOKEN + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + "token": "mytoken", + } + } + ) + assert config.get_token(PROFILE) == TOKEN + assert open_mock.return_value.write.call_count == 1 + config.delete_token(PROFILE) + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 2 -def test_load_config(hass: HomeAssistant) -> None: - """Test loading an existing token from the file.""" +def test_config_load(hass: HomeAssistant) -> None: + """Test loading from the file.""" with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_token(PROFILE) == TOKEN - - -def test_invalid_data(hass: HomeAssistant) -> None: - """Test starts with invalid data and should not raise an exception.""" - with ( - patch("builtins.open", mock_open(read_data="random characters")), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config is not None - - -def test_id_map(hass: HomeAssistant) -> None: - """Test the hass to rtm task is mapping.""" - hass_id = "hass-id-1234" - list_id = "mylist" - timeseries_id = "my_timeseries" - rtm_id = "rtm-id-4567" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), + patch( + "homeassistant.components.remember_the_milk.Path.open", + mock_open(read_data=JSON_STRING), + ), ): config = rtm.RememberTheMilkConfiguration(hass) + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is not None + assert rtm_id == ("1", "2", "3") + + +@pytest.mark.parametrize( + "side_effect", [FileNotFoundError("Missing file"), OSError("IO error")] +) +def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None: + """Test loading with file error.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.Path.open", + side_effect=side_effect, + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_load_invalid_data(hass: HomeAssistant) -> None: + """Test loading invalid data.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.Path.open", + mock_open(read_data="random characters"), + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_set_delete_id(hass: HomeAssistant) -> None: + """Test setting and deleting an id from the config.""" + hass_id = "123" + list_id = "1" + timeseries_id = "2" + rtm_id = "3" + open_mock = mock_open() + config = rtm.RememberTheMilkConfiguration(hass) + with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + config = rtm.RememberTheMilkConfiguration(hass) + assert open_mock.return_value.write.call_count == 0 assert config.get_rtm_id(PROFILE, hass_id) is None + assert open_mock.return_value.write.call_count == 0 config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id) assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id) + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": { + "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} + } + } + } + ) config.delete_rtm_id(PROFILE, hass_id) assert config.get_rtm_id(PROFILE, hass_id) is None - - -def test_load_key_map(hass: HomeAssistant) -> None: - """Test loading an existing key map from the file.""" - with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_rtm_id(PROFILE, "1234") == ("0", "1", "2") + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + } + } + ) From 09df6c870620ac7a04c84d3e38344253e9f2e560 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Sun, 16 Feb 2025 22:33:32 +0200 Subject: [PATCH 118/155] Rename "returned" state to "alert" (#138676) Rename "returned" state to "alert" in icons, services, and strings files --- homeassistant/components/seventeentrack/icons.json | 2 +- homeassistant/components/seventeentrack/services.yaml | 2 +- homeassistant/components/seventeentrack/strings.json | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index a5cac0a9f84..c48e147e973 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -19,7 +19,7 @@ "delivered": { "default": "mdi:package" }, - "returned": { + "alert": { "default": "mdi:package" }, "package": { diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index d4592dc8aab..45d7c0a530a 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -11,7 +11,7 @@ get_packages: - "ready_to_be_picked_up" - "undelivered" - "delivered" - - "returned" + - "alert" translation_key: package_state config_entry_id: required: true diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 982b15ab629..70fea2e2735 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -57,8 +57,8 @@ "delivered": { "name": "Delivered" }, - "returned": { - "name": "Returned" + "alert": { + "name": "Alert" }, "package": { "name": "Package {name}" @@ -104,7 +104,7 @@ "ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]", "undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]", "delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]", - "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]" + "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]" } } } From bdeb24cb6136c4ff522760617a1f37ee633fddd0 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sun, 16 Feb 2025 21:02:29 +0000 Subject: [PATCH 119/155] Add OptionsFlow to Squeezebox to allow setting Browse Limit and Volume Step (#129578) * Initial * prettier strings * Updates * remove error strings * prettier again * Update strings.json vscode prettier fails check * update test to remove invalid value * Remove config_entry __init__ * remove param * Review updates * ruff fixes * Review changes * Shorten options flow ui string * Review changes * Remove errant mock attib --------- Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- .../components/squeezebox/__init__.py | 5 +- .../components/squeezebox/browse_media.py | 17 +++-- .../components/squeezebox/config_flow.py | 74 ++++++++++++++++++- homeassistant/components/squeezebox/const.py | 4 + .../components/squeezebox/media_player.py | 56 +++++++++----- .../components/squeezebox/strings.json | 15 ++++ tests/components/squeezebox/conftest.py | 6 ++ .../snapshots/test_media_player.ambr | 4 +- .../components/squeezebox/test_config_flow.py | 48 +++++++++++- .../squeezebox/test_media_player.py | 12 ++- 10 files changed, 206 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 789f6ddb3a8..fd641d3389d 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -129,10 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms) - entry.runtime_data = SqueezeboxData( - coordinator=server_coordinator, - server=lms, - ) + entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) # set up player discovery known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 331bf383c70..c0458067a23 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -81,11 +81,12 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "New Music": MediaType.ALBUM, } -BROWSE_LIMIT = 1000 - async def build_item_response( - entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None] + entity: MediaPlayerEntity, + player: Player, + payload: dict[str, str | None], + browse_limit: int, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -107,7 +108,7 @@ async def build_item_response( result = await player.async_browse( MEDIA_TYPE_TO_SQUEEZEBOX[search_type], - limit=BROWSE_LIMIT, + limit=browse_limit, browse_id=browse_id, ) @@ -237,7 +238,11 @@ def media_source_content_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") -async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None: +async def generate_playlist( + player: Player, + payload: dict[str, str], + browse_limit: int, +) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] @@ -247,7 +252,7 @@ async def generate_playlist(player: Player, payload: dict[str, str]) -> list | N browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) result = await player.async_browse( - "titles", limit=BROWSE_LIMIT, browse_id=browse_id + "titles", limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 97eb848c21c..2853ad14217 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_PORT, + DEFAULT_VOLUME_STEP, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): self.data_schema = _base_schema() self.discovery_info: dict[str, Any] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + async def _discover(self, uuid: str | None = None) -> None: """Discover an unconfigured LMS server.""" self.discovery_info = None @@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): # if the player is unknown, then we likely need to configure its server return await self.async_step_user() + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_BROWSE_LIMIT): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + vol.Required(CONF_VOLUME_STEP): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER) + ), + vol.Coerce(int), + ), + } +) + + +class OptionsFlowHandler(OptionsFlow): + """Options Flow Handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Options Flow Steps.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, + { + CONF_BROWSE_LIMIT: self.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ), + CONF_VOLUME_STEP: self.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ), + }, + ), + ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 8bc33214170..f24c452282f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -32,3 +32,7 @@ SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 PLAYER_UPDATE_INTERVAL = 5 +CONF_BROWSE_LIMIT = "browse_limit" +CONF_VOLUME_STEP = "volume_step" +DEFAULT_BROWSE_LIMIT = 1000 +DEFAULT_VOLUME_STEP = 5 diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1b810019373..a98ee13275c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,10 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_BROWSE_LIMIT, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -166,6 +170,7 @@ class SqueezeBoxMediaPlayerEntity( | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SEEK @@ -184,10 +189,7 @@ class SqueezeBoxMediaPlayerEntity( _attr_name = None _last_update: datetime | None = None - def __init__( - self, - coordinator: SqueezeBoxPlayerUpdateCoordinator, - ) -> None: + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) player = coordinator.player @@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity( self._last_update = utcnow() self.async_write_ha_state() + @property + def volume_step(self) -> float: + """Return the step to be used for volume up down.""" + return float( + self.coordinator.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ) + / 100 + ) + + @property + def browse_limit(self) -> int: + """Return the step to be used for volume up down.""" + return self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) + @property def available(self) -> bool: """Return True if entity is available.""" @@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity( await self._player.async_set_power(False) await self.coordinator.async_refresh() - async def async_volume_up(self) -> None: - """Volume up media player.""" - await self._player.async_set_volume("+5") - await self.coordinator.async_refresh() - - async def async_volume_down(self) -> None: - """Volume down media player.""" - await self._player.async_set_volume("-5") - await self.coordinator.async_refresh() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" volume_percent = str(int(volume * 100)) @@ -466,7 +475,11 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": MediaType.PLAYLIST, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, + payload, + self.browse_limit, + ) except BrowseError: # a list of urls content = json.loads(media_id) @@ -477,7 +490,11 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": media_type, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, + payload, + self.browse_limit, + ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -587,7 +604,12 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_content_id, } - return await build_item_response(self, self._player, payload) + return await build_item_response( + self, + self._player, + payload, + self.browse_limit, + ) async def async_get_browse_image( self, diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index bce71ddb5f2..ed569989b56 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -103,5 +103,20 @@ "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } } + }, + "options": { + "step": { + "init": { + "title": "LMS Configuration", + "data": { + "browse_limit": "Browse limit", + "volume_step": "Volume step" + }, + "data_description": { + "browse_limit": "Maximum number of items when browsing or in a playlist.", + "volume_step": "Amount to adjust the volume when turning volume up or down." + } + } + } } } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 7b007114420..c960844ee2f 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -33,6 +33,9 @@ from homeassistant.helpers.device_registry import format_mac # from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +CONF_VOLUME_STEP = "volume_step" +TEST_VOLUME_STEP = 10 + TEST_HOST = "1.2.3.4" TEST_PORT = "9000" TEST_USE_HTTPS = False @@ -109,6 +112,9 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, const.CONF_HTTPS: TEST_USE_HTTPS, }, + options={ + CONF_VOLUME_STEP: TEST_VOLUME_STEP, + }, ) config_entry.add_to_hass(hass) return config_entry diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index fd663c5eb63..47c2fea22c5 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -65,7 +65,7 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -88,7 +88,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index c5efe66152f..cae3672061b 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,12 @@ from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN +from homeassistant.components.squeezebox.const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,6 +24,8 @@ HOST2 = "2.2.2.2" PORT = 9000 UUID = "test-uuid" UNKNOWN_ERROR = "1234" +BROWSE_LIMIT = 10 +VOLUME_STEP = 1 async def mock_discover(_discovery_callback): @@ -87,6 +94,45 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_options_form(hass: HomeAssistant) -> None: + """Test we can configure options.""" + entry = MockConfigEntry( + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + unique_id=UUID, + domain=DOMAIN, + options={CONF_BROWSE_LIMIT: 1000, CONF_VOLUME_STEP: 5}, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # simulate manual input of options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_BROWSE_LIMIT: BROWSE_LIMIT, CONF_VOLUME_STEP: VOLUME_STEP}, + ) + + # put some meaningful asserts here + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_BROWSE_LIMIT: BROWSE_LIMIT, + CONF_VOLUME_STEP: VOLUME_STEP, + } + + async def test_user_form_timeout(hass: HomeAssistant) -> None: """Test we handle server search timeout.""" with ( diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 080a2161b4d..694f5c9a8a2 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow -from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC +from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC, TEST_VOLUME_STEP from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -183,26 +183,32 @@ async def test_squeezebox_volume_up( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume up service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("+5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume + TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_down( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume down service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("-5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume - TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_set( From 93f1597e6d87437a61093f8743afecb773608ac8 Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Sun, 16 Feb 2025 22:03:57 +0100 Subject: [PATCH 120/155] Add latest Nighthawk WiFi 7 routers to V2 models (#138675) --- homeassistant/components/netgear/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index f7a683326d3..c8ecd8e7e1d 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -62,6 +62,7 @@ MODELS_V2 = [ "RBR", "RBS", "RBW", + "RS", "LBK", "LBR", "CBK", From 56b51227bb6c915f92e5b655d9b2e4e95a41f7ff Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Mon, 17 Feb 2025 02:19:03 +0100 Subject: [PATCH 121/155] Bump stookwijzer==1.5.4 (#138678) --- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 0c97d1b20ed..8243b903e8d 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.2"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index abbda498827..9d340460b1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.2 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6223d56c2f..40b9fa85762 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2257,7 +2257,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.2 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 6b90e7b2c2be3ed29222a0828a11c20114b39ac1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 16 Feb 2025 20:33:48 -0700 Subject: [PATCH 122/155] Bump pyvesync for vesync (#138681) * bump pyvesync * fix tests * Test fix --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index b3697844f19..9e2fbcc1782 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.17"] + "requirements": ["pyvesync==2.1.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d340460b1d..3adfa7abb88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40b9fa85762..6ff5c8bc7d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 1c409dbab00..407e18d65b6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -171,6 +171,7 @@ 'models': list([ 'LV-PUR131S', 'LV-RH131S', + 'LV-RH131S-WM', ]), 'modes': list([ 'manual', From c357b3ae656c2d8f7f5555077b57090131b4292b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 16 Feb 2025 23:06:28 -0500 Subject: [PATCH 123/155] Move some setups during onboarding to background (#138558) * Move some setups during onboarding to background * Update homeassistant/components/onboarding/views.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/onboarding/views.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index cb0dc4fdfa7..ea955987d80 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -33,7 +33,6 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component -from homeassistant.util.async_ import create_eager_task if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -235,22 +234,21 @@ class CoreConfigOnboardingView(_BaseOnboardingView): ): onboard_integrations.append("rpi_power") - coros: list[Coroutine[Any, Any, Any]] = [ - hass.config_entries.flow.async_init( - domain, context={"source": "onboarding"} + for domain in onboard_integrations: + # Create tasks so onboarding isn't affected + # by errors in these integrations. + hass.async_create_task( + hass.config_entries.flow.async_init( + domain, context={"source": "onboarding"} + ), + f"onboarding_setup_{domain}", ) - for domain in onboard_integrations - ] if "analytics" not in hass.config.components: # If by some chance that analytics has not finished # setting up, wait for it here so its ready for the # next step. - coros.append(async_setup_component(hass, "analytics", {})) - - # Set up integrations after onboarding and ensure - # analytics is ready for the next step. - await asyncio.gather(*(create_eager_task(coro) for coro in coros)) + await async_setup_component(hass, "analytics", {}) return self.json({}) From 89956adf2eea8d3dd6630506f6e0d9fcab436a39 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 01:47:11 -0600 Subject: [PATCH 124/155] Allow removal of stale HEOS devices (#138677) Allow device removal --- homeassistant/components/heos/__init__.py | 11 +++++++ .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/test_init.py | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 7bbd3765602..4df1a2fa0e1 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -69,3 +69,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry +) -> bool: + """Remove config entry from device if no longer present.""" + return not any( + (domain, key) + for domain, key in device.identifiers + if domain == DOMAIN and int(key) in entry.runtime_data.heos.players + ) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 67022ec492c..a1220366fa3 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -58,7 +58,7 @@ rules: icon-translations: done reconfiguration-flow: done repair-issues: todo - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 81acb7b3b8b..60bc2a72e51 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -11,10 +11,12 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from . import MockHeos from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_async_setup_entry_loads_platforms( @@ -226,3 +228,30 @@ async def test_device_id_migration_both_present( await hass.async_block_till_done(wait_background_tasks=True) assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + + +@pytest.mark.parametrize( + ("player_id", "expected_result"), + [("1", False), ("5", True)], + ids=("Present device", "Stale device"), +) +async def test_remove_config_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, + player_id: str, + expected_result: bool, +) -> None: + """Test manually removing an stale device.""" + assert await async_setup_component(hass, "config", {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, player_id)} + ) + + ws_client = await hass_ws_client(hass) + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] == expected_result From f2126a357a826e3107084b67aa6e50f246759315 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 08:58:21 +0100 Subject: [PATCH 125/155] Comply with parallel updates quality rule (#138672) --- homeassistant/components/flexit_bacnet/binary_sensor.py | 4 ++++ homeassistant/components/flexit_bacnet/climate.py | 3 +++ homeassistant/components/flexit_bacnet/number.py | 3 +++ homeassistant/components/flexit_bacnet/quality_scale.yaml | 2 +- homeassistant/components/flexit_bacnet/sensor.py | 4 ++++ homeassistant/components/flexit_bacnet/switch.py | 3 +++ 6 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py index faee803e915..50c49f45e3e 100644 --- a/homeassistant/components/flexit_bacnet/binary_sensor.py +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -47,6 +47,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class FlexitBinarySensor(FlexitEntity, BinarySensorEntity): """Representation of a Flexit binary Sensor.""" diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index abfa59d0a6d..b9ae16739b9 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -43,6 +43,9 @@ async def async_setup_entry( async_add_entities([FlexitClimateEntity(config_entry.runtime_data)]) +PARALLEL_UPDATES = 1 + + class FlexitClimateEntity(FlexitEntity, ClimateEntity): """Flexit air handling unit.""" diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index dfcfc193692..061860e7d0d 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -205,6 +205,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 1 + + class FlexitNumber(FlexitEntity, NumberEntity): """Representation of a Flexit Number.""" diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 9b7e4deb4c0..548580f96d3 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -47,7 +47,7 @@ rules: status: done comment: | Done implicitly with coordinator. - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo # Gold diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py index 23d8f20da36..0506b13892b 100644 --- a/homeassistant/components/flexit_bacnet/sensor.py +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -161,6 +161,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class FlexitSensor(FlexitEntity, SensorEntity): """Representation of a Flexit (bacnet) Sensor.""" diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index 283d0e1ec3b..ac69bb86023 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -68,6 +68,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 1 + + class FlexitSwitch(FlexitEntity, SwitchEntity): """Representation of a Flexit Switch.""" From ed3ca766964a735d5d39ff6accf9743e7069c3d9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 09:03:28 +0100 Subject: [PATCH 126/155] Update foscam action descriptions to match HA style (#138664) Update foscam action description to match HA style --- homeassistant/components/foscam/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 2784e541809..03351e3238f 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -35,7 +35,7 @@ "services": { "ptz": { "name": "PTZ", - "description": "Pan/Tilt action for Foscam camera.", + "description": "Moves a Foscam camera to a specified direction.", "fields": { "movement": { "name": "Movement", @@ -49,7 +49,7 @@ }, "ptz_preset": { "name": "PTZ preset", - "description": "PTZ Preset action for Foscam camera.", + "description": "Moves a Foscam camera to a predefined position.", "fields": { "preset_name": { "name": "Preset name", From 66d16336ea23d2f9967d547dd9544e1fc2784478 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Mon, 17 Feb 2025 08:07:18 +0000 Subject: [PATCH 127/155] Add preconditioning number entity to Ohme (#138346) * Add preconditioning number entity * Updated test snapshots for ohme * Update test snapshots --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/number.py | 14 ++++- homeassistant/components/ohme/strings.json | 3 + tests/components/ohme/conftest.py | 1 + .../ohme/snapshots/test_number.ambr | 57 +++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 7a27156b2fe..ade48b4f80f 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -6,6 +6,9 @@ } }, "number": { + "preconditioning_duration": { + "default": "mdi:fan-clock" + }, "target_percentage": { "default": "mdi:battery-heart" } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 8c5be2b48be..0c71bab009f 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from ohme import ApiException, OhmeApiClient from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +37,18 @@ NUMBER_DESCRIPTION = [ native_step=1, native_unit_of_measurement=PERCENTAGE, ), + OhmeNumberDescription( + key="preconditioning_duration", + translation_key="preconditioning_duration", + value_fn=lambda client: client.preconditioning, + set_fn=lambda client, value: client.async_set_target( + pre_condition_length=value + ), + native_min_value=0, + native_max_value=60, + native_step=5, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), ] diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index b337c013727..46ccfca71fd 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -51,6 +51,9 @@ } }, "number": { + "preconditioning_duration": { + "name": "Preconditioning duration" + }, "target_percentage": { "name": "Target percentage" } diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 3d3db730d08..01cc668ae32 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -57,6 +57,7 @@ def mock_client(): client.target_soc = 50 client.target_time = (8, 0) client.battery = 80 + client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True client.energy = 1000 diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr index dbcf6134252..69e18d0b2a7 100644 --- a/tests/components/ohme/snapshots/test_number.ambr +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning duration', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preconditioning_duration', + 'unique_id': 'chargerid_preconditioning_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Preconditioning duration', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- # name: test_numbers[number.ohme_home_pro_target_percentage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From e77193fa2e5250e33b3ac1b941be3f00d02d36fe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 09:08:40 +0100 Subject: [PATCH 128/155] Improve 17track action descriptions by using those from the online docs (#138698) * Improve 17Track action descriptions using those from the online docs Also change them to third-person singular to match the descriptive style that Home Assistant prefers. * Add missing period on 2nd description --- homeassistant/components/seventeentrack/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 70fea2e2735..c95a553ae7b 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -68,7 +68,7 @@ "services": { "get_packages": { "name": "Get packages", - "description": "Get packages from 17Track", + "description": "Queries the 17track API for the latest package data.", "fields": { "package_state": { "name": "Package states", @@ -82,7 +82,7 @@ }, "archive_package": { "name": "Archive package", - "description": "Archive a package", + "description": "Archives a package using the 17track API.", "fields": { "package_tracking_number": { "name": "Package tracking number", From cd13eff8ae89575e1786bc3a97ea4d828fda8312 Mon Sep 17 00:00:00 2001 From: Alberto Geniola Date: Mon, 17 Feb 2025 10:01:27 +0100 Subject: [PATCH 129/155] Elmax - fix issue 136877 (#138419) * Fix IPv6 zero-conf discovery not handling hostname correctly. * Aligned tests. * Remove redundant !s notation. * Add IPv6 discovery tests * Parametrize input_uri to avoid duplicated code * Update tests/components/elmax/conftest.py --------- Co-authored-by: Josef Zweck --- homeassistant/components/elmax/config_flow.py | 6 +- tests/components/elmax/__init__.py | 1 + tests/components/elmax/conftest.py | 14 +++-- tests/components/elmax/test_config_flow.py | 63 +++++++++++++++++-- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index b8697552626..98e49cc8056 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle device found via zeroconf.""" - host = discovery_info.host + host = ( + f"[{discovery_info.ip_address}]" + if discovery_info.ip_address.version == 6 + else str(discovery_info.ip_address) + ) https_port = ( int(discovery_info.port) if discovery_info.port is not None diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py index e1a6728f1f5..391c3ccbfb2 100644 --- a/tests/components/elmax/__init__.py +++ b/tests/components/elmax/__init__.py @@ -30,6 +30,7 @@ MOCK_PANEL_PIN = "000000" MOCK_WRONG_PANEL_PIN = "000000" MOCK_PASSWORD = "password" MOCK_DIRECT_HOST = "1.1.1.1" +MOCK_DIRECT_HOST_V6 = "fd00::be2:54:34:2" MOCK_DIRECT_HOST_CHANGED = "2.2.2.2" MOCK_DIRECT_PORT = 443 MOCK_DIRECT_SSL = True diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index f8cf33ffe1a..02f01036996 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -18,6 +18,7 @@ import respx from . import ( MOCK_DIRECT_HOST, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -29,6 +30,7 @@ from tests.common import load_fixture MOCK_DIRECT_BASE_URI = ( f"{'https' if MOCK_DIRECT_SSL else 'http'}://{MOCK_DIRECT_HOST}:{MOCK_DIRECT_PORT}" ) +MOCK_DIRECT_BASE_URI_V6 = f"{'https' if MOCK_DIRECT_SSL else 'http'}://[{MOCK_DIRECT_HOST_V6}]:{MOCK_DIRECT_PORT}" @pytest.fixture(autouse=True) @@ -58,12 +60,16 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: yield respx_mock +@pytest.fixture +def base_uri() -> str: + """Configure the base-uri for the respx mock fixtures.""" + return MOCK_DIRECT_BASE_URI + + @pytest.fixture(autouse=True) -def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: +def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: """Configure httpx fixture for direct Panel-API communication.""" - with respx.mock( - base_url=MOCK_DIRECT_BASE_URI, assert_all_called=False - ) as respx_mock: + with respx.mock(base_url=base_uri, assert_all_called=False) as respx_mock: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index be89ee4d5d6..379cfa98bbc 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -1,8 +1,10 @@ """Tests for the Elmax config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError +import pytest from homeassistant import config_entries from homeassistant.components.elmax.const import ( @@ -28,6 +30,7 @@ from . import ( MOCK_DIRECT_CERT, MOCK_DIRECT_HOST, MOCK_DIRECT_HOST_CHANGED, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -37,12 +40,27 @@ from . import ( MOCK_USERNAME, MOCK_WRONG_PANEL_PIN, ) +from .conftest import MOCK_DIRECT_BASE_URI_V6 from tests.common import MockConfigEntry MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], + ip_address=IPv4Address(address=MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST)], + hostname="VideoBox.local", + name="VideoBox", + port=443, + properties={ + "idl": MOCK_PANEL_ID, + "idr": MOCK_PANEL_ID, + "v1": "PHANTOM64PRO_GSM 11.9.844", + "v2": "4.9.13", + }, + type="_elmax-ssl._tcp", +) +MOCK_ZEROCONF_DISCOVERY_INFO_V6 = ZeroconfServiceInfo( + ip_address=IPv6Address(address=MOCK_DIRECT_HOST_V6), + ip_addresses=[IPv6Address(address=MOCK_DIRECT_HOST_V6)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -55,8 +73,8 @@ MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( type="_elmax-ssl._tcp", ) MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST_CHANGED, - ip_addresses=[MOCK_DIRECT_HOST_CHANGED], + ip_address=IPv4Address(address=MOCK_DIRECT_HOST_CHANGED), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST_CHANGED)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -69,8 +87,8 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( type="_elmax-ssl._tcp", ) MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], + ip_address=IPv4Address(MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(MOCK_DIRECT_HOST)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -194,6 +212,18 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: assert result["errors"] is None +async def test_zeroconf_discovery_ipv6(hass: HomeAssistant) -> None: + """Test discovery of Elmax local api panel.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_setup" + assert result["errors"] is None + + async def test_zeroconf_setup_show_form(hass: HomeAssistant) -> None: """Test discovery shows a form when activated.""" result = await hass.config_entries.flow.async_init( @@ -230,6 +260,27 @@ async def test_zeroconf_setup(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.parametrize("base_uri", [MOCK_DIRECT_BASE_URI_V6]) +async def test_zeroconf_ipv6_setup(hass: HomeAssistant) -> None: + """Test the successful creation of config entry via discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_MODE_DIRECT_SSL: MOCK_DIRECT_SSL, + }, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Ensure local discovery aborts when same panel is already added to ha.""" MockConfigEntry( From 1fe644d0567019b6aa1c62b063a22c0506071f37 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 11:05:39 +0100 Subject: [PATCH 130/155] Fix casing in Sensibo action descriptions (#138701) - treat "Pure Boost" as a feature name - fix sentence-casing - capitalize first word --- homeassistant/components/sensibo/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6c5210d12bf..6aba2be52fc 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -429,16 +429,16 @@ } }, "enable_pure_boost": { - "name": "Enable pure boost", + "name": "Enable Pure Boost", "description": "Enables and configures Pure Boost settings.", "fields": { "ac_integration": { "name": "AC integration", - "description": "Integrate with Air Conditioner." + "description": "Integrate with air conditioner." }, "geo_integration": { "name": "Geo integration", - "description": "Integrate with Presence." + "description": "Integrate with presence." }, "indoor_integration": { "name": "Indoor air quality", @@ -468,7 +468,7 @@ }, "fan_mode": { "name": "Fan mode", - "description": "set fan mode." + "description": "Set fan mode." }, "swing_mode": { "name": "Swing mode", From 168e45b0f9b357b80096b991e1e470e0943b028b Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 17 Feb 2025 19:24:56 +0800 Subject: [PATCH 131/155] Bump yolink api 0.4.8 (#138703) --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 78b553d7978..52ae8281f59 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.7"] + "requirements": ["yolink-api==0.4.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3adfa7abb88..9153674fdcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3110,7 +3110,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ff5c8bc7d7..164562a485b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2505,7 +2505,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless youless-api==2.2.0 From b4fac38d8a658fef1700440996cf11d780cca01d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 17 Feb 2025 12:42:02 +0100 Subject: [PATCH 132/155] Bump uv to 0.6.0 (#138707) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 19b2c97b181..42a90107c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.27 +RUN pip3 install uv==0.6.0 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7aa76de2620..2b9e5c307a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.27 +uv==0.6.0 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 66b25b75f92..44fef7dea9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.27", + "uv==0.6.0", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 2cbd3780eae..c06beefab37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.27 +uv==0.6.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5598c839257..9d652ec1641 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.0,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From a7f63e3847b38785eb6640b433faae6ac60a559e Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:02:52 +0800 Subject: [PATCH 133/155] Optimize Refoss state_class of Sensor (#138266) TOTAL_INCREASING --- homeassistant/components/refoss/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 82637aae538..92090a192e8 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy", translation_key="this_month_energy", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", @@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy_returned", translation_key="this_month_energy_returned", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", From df6cb0b824972fa990616c3e6cd774b470bb1cd2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:03:31 +0100 Subject: [PATCH 134/155] Add repair-issue that backup location setup is missing in Synology DSM (#138233) * add missing backup location setup repair-issue * add tests * tweak translation strings * add test for other fixable issues * remove senseless abort reason no_file_station --- .../components/synology_dsm/common.py | 17 + .../components/synology_dsm/const.py | 2 + .../components/synology_dsm/repairs.py | 125 +++++++ .../components/synology_dsm/strings.json | 31 ++ tests/components/synology_dsm/test_repairs.py | 321 ++++++++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 homeassistant/components/synology_dsm/repairs.py create mode 100644 tests/components/synology_dsm/test_repairs.py diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index dfc372e6bde..d61944c146d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -35,13 +35,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + CONF_BACKUP_PATH, CONF_DEVICE_TOKEN, DEFAULT_TIMEOUT, + DOMAIN, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, + ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) @@ -174,6 +178,19 @@ class SynoApi: " permissions or no writable shared folders available" ) + if shares and not self._entry.options.get(CONF_BACKUP_PATH): + ir.async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}", + data={"entry_id": self._entry.entry_id}, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MISSING_BACKUP_SETUP, + translation_placeholders={"title": self._entry.title}, + ) + LOGGER.debug( "State of File Station during setup of '%s': %s", self._entry.unique_id, diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 8fb436e8fa6..758fad53970 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -35,6 +35,8 @@ PLATFORMS = [ EXCEPTION_DETAILS = "details" EXCEPTION_UNKNOWN = "unknown" +ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup" + # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py new file mode 100644 index 00000000000..725e77a2593 --- /dev/null +++ b/homeassistant/components/synology_dsm/repairs.py @@ -0,0 +1,125 @@ +"""Repair flows for the Synology DSM integration.""" + +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import cast + +from synology_dsm.api.file_station.models import SynoFileSharedFolder +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, + ISSUE_MISSING_BACKUP_SETUP, + SYNOLOGY_CONNECTION_EXCEPTIONS, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +class MissingBackupSetupRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + """Create flow.""" + self.entry = entry + self.issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + }, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.entry, options={**dict(self.entry.options), **user_input} + ) + return self.async_create_entry(data={}) + + shares: list[SynoFileSharedFolder] | None = None + if syno_data.api.file_station: + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await syno_data.api.file_station.get_shared_folders( + only_writable=True + ) + + if not shares: + return self.async_abort(reason="no_shares") + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.entry.options[CONF_BACKUP_SHARE], + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=self.entry.options[CONF_BACKUP_PATH], + ): str, + } + ), + ) + + async def async_step_ignore( + self, _: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) + return self.async_abort(reason="ignored") + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP): + return MissingBackupSetupRepairFlow(entry, issue_id) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index c14f8da1037..f51184ef1cb 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -185,6 +185,37 @@ } } }, + "issues": { + "missing_backup_setup": { + "title": "Backup location not configured for {title}", + "fix_flow": { + "step": { + "init": { + "description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})", + "menu_options": { + "confirm": "Set up the backup location now", + "ignore": "Don't set it up now" + } + }, + "confirm": { + "title": "[%key:component::synology_dsm::config::step::backup_share::title%]", + "data": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" + } + } + }, + "abort": { + "no_shares": "There are no shared folders available for the user.\nPlease check the documentation.", + "ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options." + } + } + } + }, "services": { "reboot": { "name": "Reboot", diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py new file mode 100644 index 00000000000..b2e7352f214 --- /dev/null +++ b/tests/components/synology_dsm/test_repairs.py @@ -0,0 +1,321 @@ +"""Test repairs for synology dsm.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import ANY, MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def mock_dsm_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_filestation( + hass: HomeAssistant, + mock_dsm_with_filestation: MagicMock, +): + """Mock setup of synology dsm config entry.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={ + CONF_BACKUP_PATH: None, + CONF_BACKUP_SHARE: None, + }, + unique_id="my_serial", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, REPAIRS_DOMAIN, {}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_create_issue( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the issue is created.""" + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["breaks_in_ha_version"] is None + assert issue["domain"] == DOMAIN + assert issue["issue_id"] == "missing_backup_setup_my_serial" + assert issue["translation_key"] == "missing_backup_setup" + + +async def test_missing_backup_ignore( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test missing backup location setup issue is ignored by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to ignore the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "ignore"} + ) + assert data["type"] == "abort" + assert data["reason"] == "ignored" + + # check issue is ignored + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["ignored"] + + +async def test_missing_backup_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow is fully processed by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.options == {"backup_path": None, "backup_share": None} + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["step_id"] == "confirm" + assert data["type"] == "form" + + # fill out the form and submit + data = await process_repair_fix_flow( + client, + flow_id, + json={"backup_share": "/ha_backup", "backup_path": "backup_ha_dev"}, + ) + assert data["type"] == "create_entry" + assert entry.options == { + "backup_path": "backup_ha_dev", + "backup_share": "/ha_backup", + } + + +async def test_missing_backup_no_shares( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow errors out.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # inject error + setup_dsm_with_filestation.file.get_shared_folders.return_value = [] + + # select to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["type"] == "abort" + assert data["reason"] == "no_shares" + + +@pytest.mark.parametrize( + "ignore_translations", + ["component.synology_dsm.issues.other_issue.title"], +) +async def test_other_fixable_issues( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing another issue.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": None, + "domain": DOMAIN, + "issue_id": "other_issue", + "is_fixable": True, + "severity": "error", + "translation_key": "other_issue", + } + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + is_fixable=issue["is_fixable"], + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "synology_dsm", + "ignored": False, + "is_fixable": True, + "issue_domain": None, + "issue_id": "other_issue", + "learn_more_url": None, + "severity": "error", + "translation_key": "other_issue", + "translation_placeholders": None, + } in results + + data = await start_repair_fix_flow(client, DOMAIN, "other_issue") + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == "create_entry" + await hass.async_block_till_done() From 4a385ed26c2cc4adaf50a33352340c6acda73726 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 13:38:42 +0100 Subject: [PATCH 135/155] Use correct camel-case for OpenThread, reword error message (#138651) * Use correct camel-case for OpenThread, reword error message * Treat "Border Agent ID" as a name by capitalizing it --- homeassistant/components/otbr/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index e1afa5b8909..3a9661c454d 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -5,7 +5,7 @@ "data": { "url": "[%key:common::config_flow::data::url%]" }, - "description": "Provide URL for the Open Thread Border Router's REST API" + "description": "Provide URL for the OpenThread Border Router's REST API" } }, "error": { @@ -20,8 +20,8 @@ }, "issues": { "get_get_border_agent_id_unsupported": { - "title": "The OTBR does not support border agent ID", - "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR." + "title": "The OTBR does not support Border Agent ID", + "description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR." }, "insecure_thread_network": { "title": "Insecure Thread network settings detected", From d8d054e7dd62ce0f0e83c48cf7c466b266edb989 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:45:00 +0100 Subject: [PATCH 136/155] Improve type hints in base entities (#138708) --- homeassistant/components/broadlink/entity.py | 6 +++--- homeassistant/components/enocean/entity.py | 2 +- homeassistant/components/flo/entity.py | 4 ++-- homeassistant/components/hlk_sw16/entity.py | 4 ++-- homeassistant/components/homematic/entity.py | 4 ++-- homeassistant/components/ihc/entity.py | 2 +- homeassistant/components/insteon/entity.py | 4 ++-- homeassistant/components/lupusec/entity.py | 2 +- homeassistant/components/lutron_caseta/entity.py | 2 +- homeassistant/components/onvif/entity.py | 2 +- homeassistant/components/pilight/entity.py | 4 ++-- homeassistant/components/plaato/entity.py | 4 ++-- homeassistant/components/point/entity.py | 4 ++-- homeassistant/components/qwikswitch/entity.py | 2 +- homeassistant/components/raincloud/entity.py | 2 +- homeassistant/components/rflink/entity.py | 8 ++++---- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/soma/entity.py | 2 +- homeassistant/components/starline/entity.py | 8 ++++---- homeassistant/components/tellduslive/entity.py | 6 +++--- homeassistant/components/tellstick/entity.py | 4 ++-- homeassistant/components/upb/entity.py | 4 ++-- homeassistant/components/velux/entity.py | 2 +- homeassistant/components/vera/entity.py | 4 ++-- homeassistant/components/volvooncall/entity.py | 2 +- homeassistant/components/wiffi/entity.py | 2 +- homeassistant/components/wirelesstag/entity.py | 4 ++-- homeassistant/components/xiaomi_aqara/entity.py | 4 ++-- homeassistant/components/xiaomi_miio/entity.py | 2 +- homeassistant/components/xs1/entity.py | 2 +- homeassistant/components/yamaha_musiccast/entity.py | 4 ++-- 31 files changed, 54 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 6c956d8c80a..a97374680f9 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -17,13 +17,13 @@ class BroadlinkEntity(Entity): self._device = device self._coordinator = device.update_manager.coordinator - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" self.async_on_remove(self._coordinator.async_add_listener(self._recv_data)) if self._coordinator.data: self._update_state(self._coordinator.data) - async def async_update(self): + async def async_update(self) -> None: """Update the state of the entity.""" await self._coordinator.async_request_refresh() @@ -49,7 +49,7 @@ class BroadlinkEntity(Entity): """ @property - def available(self): + def available(self) -> bool: """Return True if the entity is available.""" return self._device.available diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py index 5c12fc12a68..b2d73e65443 100644 --- a/homeassistant/components/enocean/entity.py +++ b/homeassistant/components/enocean/entity.py @@ -16,7 +16,7 @@ class EnOceanEntity(Entity): """Initialize the device.""" self.dev_id = dev_id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index b0cf8d04313..072afbae4f2 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -45,10 +45,10 @@ class FloEntity(Entity): """Return True if device is available.""" return self._device.available - async def async_update(self): + async def async_update(self) -> None: """Update Flo entity.""" await self._device.async_request_refresh() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state)) diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py index fdef5f6764b..91510760968 100644 --- a/homeassistant/components/hlk_sw16/entity.py +++ b/homeassistant/components/hlk_sw16/entity.py @@ -35,7 +35,7 @@ class SW16Entity(Entity): self.async_write_ha_state() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._client.is_connected) @@ -44,7 +44,7 @@ class SW16Entity(Entity): """Update availability state.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" self._client.register_status_callback( self.handle_event_callback, self._device_port diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 5a5b2a3b8c8..44e95e98f38 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -62,7 +62,7 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Load data init callbacks.""" self._subscribe_homematic_events() @@ -77,7 +77,7 @@ class HMDevice(Entity): return self._name @property - def available(self): + def available(self) -> bool: """Return true if device is available.""" return self._available diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index f90b2ee943c..8847ffc9f49 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -54,7 +54,7 @@ class IHCEntity(Entity): self.ihc_note = "" self.ihc_position = "" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add callback for IHC changes.""" _LOGGER.debug("Adding IHC entity notify event: %s", self.ihc_id) self.ihc_controller.add_notify_event(self.ihc_id, self.on_ihc_change, True) diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py index 79e5c18a934..b7886723fdf 100644 --- a/homeassistant/components/insteon/entity.py +++ b/homeassistant/components/insteon/entity.py @@ -109,7 +109,7 @@ class InsteonEntity(Entity): ) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register INSTEON update events.""" _LOGGER.debug( "Tracking updates for device %s group %d name %s", @@ -137,7 +137,7 @@ class InsteonEntity(Entity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe to INSTEON update events.""" _LOGGER.debug( "Remove tracking updates for device %s group %d name %s", diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py index dc0dac89dc8..8cfb559b84f 100644 --- a/homeassistant/components/lupusec/entity.py +++ b/homeassistant/components/lupusec/entity.py @@ -18,7 +18,7 @@ class LupusecDevice(Entity): self._device = device self._attr_unique_id = device.device_id - def update(self): + def update(self) -> None: """Update automation state.""" self._device.refresh() diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index f954be74f1d..5ab211ed87b 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -63,7 +63,7 @@ class LutronCasetaEntity(Entity): info[ATTR_SUGGESTED_AREA] = area self._attr_device_info = info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py index c9900106256..783df743e86 100644 --- a/homeassistant/components/onvif/entity.py +++ b/homeassistant/components/onvif/entity.py @@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity): self.device: ONVIFDevice = device @property - def available(self): + def available(self) -> bool: """Return True if device is available.""" return self.device.available diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index fbb924d7f8f..fbfa5cfb5e1 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity): self._brightness = 255 - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): @@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity): return self._name @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 7ab8367bd1d..9cc63a38a64 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity): return None @property - def available(self): + def available(self) -> bool: """Return if sensor is available.""" if self._coordinator is not None: return self._coordinator.last_update_success return True - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" if self._coordinator is not None: self.async_on_remove( diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 4784dd43180..5c52e81e6f7 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -52,7 +52,7 @@ class MinutPointEntity(Entity): ) await self._update_callback() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: self._async_unsub_dispatcher_connect() @@ -61,7 +61,7 @@ class MinutPointEntity(Entity): """Update the value of the sensor.""" @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py index 3a2ec5a9206..ff7a1d2e98a 100644 --- a/homeassistant/components/qwikswitch/entity.py +++ b/homeassistant/components/qwikswitch/entity.py @@ -35,7 +35,7 @@ class QSEntity(Entity): """Receive update packet from QSUSB. Match dispather_send signature.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Listen for updates from QSUSb via dispatcher.""" self.async_on_remove( async_dispatcher_connect(self.hass, self.qsid, self.update_packet) diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py index 337324d96eb..b45684ac72b 100644 --- a/homeassistant/components/raincloud/entity.py +++ b/homeassistant/components/raincloud/entity.py @@ -45,7 +45,7 @@ class RainCloudEntity(Entity): """Return the name of the sensor.""" return self._name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index 26153acf7ba..0caec4ea2c3 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -105,12 +105,12 @@ class RflinkDevice(Entity): return self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Assume device state until first device event sets state.""" return self._state is None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @@ -120,7 +120,7 @@ class RflinkDevice(Entity): self._available = availability self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" await super().async_added_to_hass() # Remove temporary bogus entity_id if added @@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice): class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): """Rflink entity which can switch on/off (eg: light, switch).""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFLink device state (ON/OFF).""" await super().async_added_to_hass() if (old_state := await self.async_get_last_state()) is not None: diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index ae5577da4e4..14c7ac3af3e 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -80,7 +80,7 @@ class IRobotEntity(Entity): return None return dt_util.utc_from_timestamp(ts) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py index f9824d107b1..4b2fcee5405 100644 --- a/homeassistant/components/soma/entity.py +++ b/homeassistant/components/soma/entity.py @@ -71,7 +71,7 @@ class SomaEntity(Entity): self.api_is_available = True @property - def available(self): + def available(self) -> bool: """Return true if the last API commands returned successfully.""" return self.is_available diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 74807996dfb..f8846c2a97f 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -27,20 +27,20 @@ class StarlineEntity(Entity): self._unsubscribe_api: Callable | None = None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._account.api.available - def update(self): + def update(self) -> None: """Read new state data.""" self.schedule_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() self._unsubscribe_api = self._account.api.add_update_listener(self.update) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Call when entity is being removed from Home Assistant.""" await super().async_will_remove_from_hass() if self._unsubscribe_api is not None: diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py index a71fcb685c0..5366e4c27df 100644 --- a/homeassistant/components/tellduslive/entity.py +++ b/homeassistant/components/tellduslive/entity.py @@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity): self._id = device_id self._client = client - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) self.async_on_remove( @@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity): return self.device.state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py index 746c7f4dd4d..5be3d1f48f4 100644 --- a/homeassistant/components/tellstick/entity.py +++ b/homeassistant/components/tellstick/entity.py @@ -40,7 +40,7 @@ class TellstickDevice(Entity): self._attr_name = tellcore_device.name self._attr_unique_id = tellcore_device.id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -146,6 +146,6 @@ class TellstickDevice(Entity): except TelldusError as err: _LOGGER.error(err) - def update(self): + def update(self) -> None: """Poll the current state of the device.""" self._update_from_tellcore() diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py index 13037adf680..8a9afa453b1 100644 --- a/homeassistant/components/upb/entity.py +++ b/homeassistant/components/upb/entity.py @@ -30,7 +30,7 @@ class UpbEntity(Entity): return self._element.as_dict() @property - def available(self): + def available(self) -> bool: """Is the entity available to be updated.""" return self._upb.is_connected() @@ -43,7 +43,7 @@ class UpbEntity(Entity): self._element_changed(element, changeset) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback for UPB changes and update entity state.""" self._element.add_callback(self._element_callback) self._element_callback(self._element, {}) diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 674ba5dde45..1231a98e0a8 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -31,6 +31,6 @@ class VeluxEntity(Entity): self.node.register_device_updated_cb(after_update_callback) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Store register state change callback.""" self.async_register_callbacks() diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index 84e21e54983..b3013c288c1 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Update the state.""" self.schedule_update_ha_state(True) - def update(self): + def update(self) -> None: """Force a refresh from the device if the device is unavailable.""" refresh_needed = self.vera_device.should_poll or not self.available _LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed) @@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): return attr @property - def available(self): + def available(self) -> bool: """If device communications have failed return false.""" return not self.vera_device.comm_failure diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py index 6ebc4bdc754..5a1194e8b1a 100644 --- a/homeassistant/components/volvooncall/entity.py +++ b/homeassistant/components/volvooncall/entity.py @@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): return f"{self._vehicle_name} {self._entity_name}" @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py index fd774c930c8..84bbc9b3df1 100644 --- a/homeassistant/components/wiffi/entity.py +++ b/homeassistant/components/wiffi/entity.py @@ -41,7 +41,7 @@ class WiffiEntity(Entity): self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py index 31f8ee99d0d..73b13cdc397 100644 --- a/homeassistant/components/wirelesstag/entity.py +++ b/homeassistant/components/wirelesstag/entity.py @@ -60,11 +60,11 @@ class WirelessTagBaseSensor(Entity): return f"{value:.1f}" @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._tag.is_alive - def update(self): + def update(self) -> None: """Update state.""" if not self.should_poll: return diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index db47015c0cf..59107984ddf 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -57,7 +57,7 @@ class XiaomiDevice(Entity): self._is_gateway = False self._device_id = self._sid - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Start unavailability tracking.""" self._xiaomi_hub.callbacks[self._sid].append(self.push_data) self._async_track_unavailable() @@ -100,7 +100,7 @@ class XiaomiDevice(Entity): return device_info @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._is_available diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index 0343a7526d7..ba1148985ba 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -185,7 +185,7 @@ class XiaomiGatewayDevice(CoordinatorEntity, Entity): ) @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" if self.coordinator.data is None: return False diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py index 7239a6fd446..c1ec43ec33c 100644 --- a/homeassistant/components/xs1/entity.py +++ b/homeassistant/components/xs1/entity.py @@ -17,7 +17,7 @@ class XS1DeviceEntity(Entity): """Initialize the XS1 device.""" self.device = device - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest device state.""" async with UPDATE_LOCK: await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index 4f1add825e4..8023b13c10a 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -78,13 +78,13 @@ class MusicCastDeviceEntity(MusicCastEntity): return device_info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" await super().async_added_to_hass() # All entities should register callbacks to update HA when their state changes self.coordinator.musiccast.register_callback(self.async_write_ha_state) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Entity being removed from hass.""" await super().async_will_remove_from_hass() self.coordinator.musiccast.remove_callback(self.async_write_ha_state) From 7e388f69b0f3b009aef587c1ed70e7a60cef87b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:45:32 +0100 Subject: [PATCH 137/155] Add common entity module to pylint plugin (#138706) * Add common entity module to pylint plugin * Fix pylint errors --- homeassistant/components/isy994/entity.py | 4 ++-- homeassistant/components/switchbot/entity.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 893b33644fe..1da727fdee8 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -106,7 +106,7 @@ class ISYNodeEntity(ISYEntity): return getattr(self._node, TAG_ENABLED, True) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device. The 'aux_properties' in the pyisy Node class are combined with the @@ -189,7 +189,7 @@ class ISYProgramEntity(ISYEntity): self._actions = actions @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device.""" attr = {} if self._actions: diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index bde69429bc3..282d23bfd1a 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -61,7 +61,7 @@ class SwitchbotEntity( return self.coordinator.device.parsed_data @property - def extra_state_attributes(self) -> Mapping[Any, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" return {"last_run_success": self._last_run_success} diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e2b6de6e6a3..a4590207294 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1410,6 +1410,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "entity": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), + ], "fan": [ ClassTypeHintMatch( base_class="Entity", From 51aea58c7ac441a3d4c84935ca4a2be4d9d5e23c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:46:33 +0100 Subject: [PATCH 138/155] Update mypy-dev to 1.16.0a3 (#138655) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 2731114043b..0a7a3bb18e5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.10 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a2 +mypy-dev==1.16.0a3 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 From 4cdc3de94a491fdf7fb98667666e3656b66806e5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Feb 2025 15:38:28 +0100 Subject: [PATCH 139/155] Correct backup filename on delete or download of cloud backup (#138704) * Correct backup filename on delete or download of cloud backup * Improve tests * Address review comments --- homeassistant/components/cloud/backup.py | 43 +++++++++++------ tests/components/cloud/test_backup.py | 61 +++++++++++++++++++++--- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 61edeccdd9c..b31fe16fbe9 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -11,7 +11,11 @@ from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.cloud_api import ( + FilesHandlerListEntry, + async_files_delete_file, + async_files_list, +) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -76,11 +80,6 @@ class CloudBackupAgent(BackupAgent): self._cloud = cloud self._hass = hass - @callback - def _get_backup_filename(self) -> str: - """Return the backup filename.""" - return f"{self._cloud.client.prefs.instance_id}.tar" - async def async_download_backup( self, backup_id: str, @@ -91,13 +90,13 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): raise BackupAgentError("Backup not found") try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except CloudError as err: raise BackupAgentError(f"Failed to download backup: {err}") from err @@ -124,7 +123,7 @@ class CloudBackupAgent(BackupAgent): base64md5hash = await calculate_b64md5(open_stream, size) except FilesError as err: raise BackupAgentError(err) from err - filename = self._get_backup_filename() + filename = f"{self._cloud.client.prefs.instance_id}.tar" metadata = backup.as_dict() tries = 1 @@ -172,29 +171,34 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): return try: await async_files_delete_file( self._cloud, storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to delete backup") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await self._async_list_backups() + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def _async_list_backups(self) -> list[FilesHandlerListEntry]: """List backups.""" try: backups = await async_files_list( self._cloud, storage_type=StorageType.BACKUP ) - _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err - return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + _LOGGER.debug("Cloud backups: %s", backups) + return backups async def async_get_backup( self, @@ -202,10 +206,19 @@ class CloudBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - backups = await self.async_list_backups() + if not (backup := await self._async_get_backup(backup_id)): + return None + return AgentBackup.from_dict(backup["Metadata"]) + + async def _async_get_backup( + self, + backup_id: str, + ) -> FilesHandlerListEntry | None: + """Return a backup.""" + backups = await self._async_list_backups() for backup in backups: - if backup.backup_id == backup_id: + if backup["Metadata"]["backup_id"] == backup_id: return backup return None diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c6bb0bdad54..18793cc00bb 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,12 +3,12 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import ANY, Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.files import FilesError +from hass_nabucasa.files import FilesError, StorageType import pytest from homeassistant.components.backup import ( @@ -90,7 +90,26 @@ def mock_list_files() -> Generator[MagicMock]: "size": 34519040, "storage-type": "backup", }, - } + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", + }, + }, ] yield list_files @@ -148,7 +167,21 @@ async def test_agents_list_backups( "name": "Core 2024.12.0.dev0", "failed_agent_ids": [], "with_automatic_settings": None, - } + }, + { + "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + }, ] @@ -242,6 +275,10 @@ async def test_agents_download( resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" + cloud.files.download.assert_called_once_with( + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") @@ -317,7 +354,14 @@ async def test_agents_upload( data={"file": StringIO(backup_data)}, ) - assert len(cloud.files.upload.mock_calls) == 1 + cloud.files.upload.assert_called_once_with( + storage_type=StorageType.BACKUP, + open_stream=ANY, + filename=f"{cloud.client.prefs.instance_id}.tar", + base64md5hash=ANY, + metadata=ANY, + size=ANY, + ) metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] assert metadata["backup_id"] == backup_id @@ -552,6 +596,7 @@ async def test_agents_upload_wrong_size( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + cloud: Mock, mock_delete_file: Mock, ) -> None: """Test agent delete backup.""" @@ -568,7 +613,11 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_delete_file.assert_called_once() + mock_delete_file.assert_called_once_with( + cloud, + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) From 9422c4de65aafc693440af1f4b8f41fbd7d17744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 17 Feb 2025 15:01:03 +0000 Subject: [PATCH 140/155] Fix snapshots timezone in Cloud tests (#138393) * Fix snapshots timezone in Cloud tests * Add explanation comment --- tests/components/cloud/test_http_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c8852b911e9..ef4b93a8aab 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1943,7 +1943,10 @@ async def test_download_support_package( ) now = dt_util.utcnow() - freezer.move_to(datetime.datetime.fromisoformat("2025-02-10T12:00:00.0+00:00")) + # The logging is done with local time according to the system timezone. Set the + # fake time to 12:00 local time + tz = now.astimezone().tzinfo + freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz)) logging.getLogger("hass_nabucasa.iot").info( "This message will be dropped since this test patches MAX_RECORDS" ) From 82f2e72327c7baa5cf6b700baeef93015d096def Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 16:18:46 +0100 Subject: [PATCH 141/155] Add translations for exceptions (#138669) * Add translations for exceptions * Review comment * Add translation for exception in the coordinator * Use same translation string for switch exceptions --- .../components/flexit_bacnet/climate.py | 17 +++++++++++++-- .../components/flexit_bacnet/coordinator.py | 6 +++++- .../components/flexit_bacnet/number.py | 9 +++++++- .../flexit_bacnet/quality_scale.yaml | 2 +- .../components/flexit_bacnet/strings.json | 17 +++++++++++++++ .../components/flexit_bacnet/switch.py | 21 +++++++++++++++---- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index b9ae16739b9..7dc855e3106 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -25,6 +25,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( + DOMAIN, MAX_TEMP, MIN_TEMP, PRESET_TO_VENTILATION_MODE_MAP, @@ -133,7 +134,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): try: await self.device.set_ventilation_mode(ventilation_mode) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_preset_mode", + translation_placeholders={ + "preset": str(ventilation_mode), + }, + ) from exc finally: await self.coordinator.async_refresh() @@ -153,6 +160,12 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): else: await self.device.set_ventilation_mode(VENTILATION_MODE_HOME) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_hvac_mode", + translation_placeholders={ + "mode": str(hvac_mode), + }, + ) from exc finally: await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index da9415f2b87..9148ec87883 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -49,7 +49,11 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): await self.device.update() except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise ConfigEntryNotReady( - f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}" + translation_domain=DOMAIN, + translation_key="not_ready", + translation_placeholders={ + "ip": str(self.config_entry.data[CONF_IP_ADDRESS]), + }, ) from exc return self.device diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 061860e7d0d..b8c329bd1d4 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -249,6 +250,12 @@ class FlexitNumber(FlexitEntity, NumberEntity): try: await set_native_value_fn(int(value)) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_value_error", + translation_placeholders={ + "value": str(value), + }, + ) from exc finally: await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 548580f96d3..f59435bad0d 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -62,7 +62,7 @@ rules: comment: | Device type integration. diagnostics: todo - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo dynamic-devices: diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 488d93fbd61..e9acbd46a37 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -119,5 +119,22 @@ "name": "Cooker hood mode" } } + }, + "exceptions": { + "set_value_error": { + "message": "Failed setting the value {value}." + }, + "switch_turn": { + "message": "Failed to turn the switch {state}." + }, + "set_preset_mode": { + "message": "Failed to set preset mode {preset}." + }, + "set_hvac_mode": { + "message": "Failed to set HVAC mode {mode}." + }, + "not_ready": { + "message": "Timeout while connecting to {ip}." + } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index ac69bb86023..bdeff006181 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -97,19 +98,31 @@ class FlexitSwitch(FlexitEntity, SwitchEntity): return self.entity_description.is_on_fn(self.coordinator.data) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn electric heater on.""" + """Turn switch on.""" try: await self.entity_description.turn_on_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn", + translation_placeholders={ + "state": "on", + }, + ) from exc finally: await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn electric heater off.""" + """Turn switch off.""" try: await self.entity_description.turn_off_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn", + translation_placeholders={ + "state": "off", + }, + ) from exc finally: await self.coordinator.async_refresh() From 34a33e0465e6d34d9f831857bd32135bb0af15a5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 09:28:55 -0600 Subject: [PATCH 142/155] Create HEOS devices after integration setup (#138721) * Create entities for new players * Fix docstring typo --- homeassistant/components/heos/coordinator.py | 30 ++++++-- homeassistant/components/heos/media_player.py | 17 +++-- .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/conftest.py | 62 ++++++++------- tests/components/heos/test_init.py | 75 ++++++++++++++++++- 5 files changed, 147 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 94aa4ad0ab5..0303d150794 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -16,6 +16,7 @@ from pyheos import ( HeosError, HeosNowPlayingMedia, HeosOptions, + HeosPlayer, MediaItem, MediaType, PlayerUpdateResult, @@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): credentials=credentials, ) ) + self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] self._update_sources_pending: bool = False self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} @@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self.async_update_listeners() return remove_listener + def async_add_platform_callback( + self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None] + ) -> None: + """Add a callback to add entities for a platform.""" + self._platform_callbacks.append(add_entities_callback) + + def _async_handle_player_update_result( + self, update_result: PlayerUpdateResult + ) -> None: + """Handle a player update result.""" + if update_result.added_player_ids and self._platform_callbacks: + new_players = [ + self.heos.players[player_id] + for player_id in update_result.added_player_ids + ] + for add_entities_callback in self._platform_callbacks: + add_entities_callback(new_players) + + if update_result.updated_player_ids: + self._async_update_player_ids(update_result.updated_player_ids) + async def _async_on_auth_failure(self) -> None: """Handle when the user credentials are no longer valid.""" assert self.config_entry is not None @@ -147,8 +170,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: assert data is not None - if data.updated_player_ids: - self._async_update_player_ids(data.updated_player_ids) + self._async_handle_player_update_result(data) elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending @@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): except HeosError as error: _LOGGER.error("Unable to refresh players: %s", error) return - # After reconnecting, player_id may have changed - if player_updates.updated_player_ids: - self._async_update_player_ids(player_updates.updated_player_ids) + self._async_handle_player_update_result(player_updates) @callback def async_get_source_list(self) -> list[str]: diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4dbaead67a7..b9aa05810e5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine, Sequence from datetime import datetime from functools import reduce, wraps from operator import ior @@ -93,11 +93,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - devices = [ - HeosMediaPlayer(entry.runtime_data, player) - for player in entry.runtime_data.heos.players.values() - ] - async_add_entities(devices) + + def add_entities_callback(players: Sequence[HeosPlayer]) -> None: + """Add entities for each player.""" + async_add_entities( + [HeosMediaPlayer(entry.runtime_data, player) for player in players] + ) + + coordinator = entry.runtime_data + coordinator.async_add_platform_callback(add_entities_callback) + add_entities_callback(list(coordinator.heos.players.values())) type _FuncType[**_P] = Callable[_P, Awaitable[Any]] diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index a1220366fa3..a08e2dca544 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -49,7 +49,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 39937a8355f..7bed05a0289 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( @@ -130,16 +130,17 @@ def system_info_fixture() -> HeosSystem: ) -@pytest.fixture(name="players") -def players_fixture() -> dict[int, HeosPlayer]: - """Create two mock HeosPlayers.""" - players = {} - for i in (1, 2): - player = HeosPlayer( - player_id=i, +@pytest.fixture(name="player_factory") +def player_factory_fixture() -> Callable[[int, str, str], HeosPlayer]: + """Return a method that creates players.""" + + def factory(player_id: int, name: str, model: str) -> HeosPlayer: + """Create a player.""" + return HeosPlayer( + player_id=player_id, group_id=999, - name="Test Player" if i == 1 else f"Test Player {i}", - model="HEOS Drive HS2" if i == 1 else "Speaker", + name=name, + model=model, serial="123456", version="1.0.0", supported_version=True, @@ -147,26 +148,37 @@ def players_fixture() -> dict[int, HeosPlayer]: is_muted=False, available=True, state=PlayState.STOP, - ip_address=f"127.0.0.{i}", + ip_address=f"127.0.0.{player_id}", network=NetworkType.WIRED, shuffle=False, repeat=RepeatType.OFF, volume=25, + now_playing_media=HeosNowPlayingMedia( + type=MediaType.STATION, + song="Song", + station="Station Name", + album="Album", + artist="Artist", + image_url="http://", + album_id="1", + media_id="1", + queue_id=1, + source_id=10, + ), ) - player.now_playing_media = HeosNowPlayingMedia( - type=MediaType.STATION, - song="Song", - station="Station Name", - album="Album", - artist="Artist", - image_url="http://", - album_id="1", - media_id="1", - queue_id=1, - source_id=10, - ) - players[player.player_id] = player - return players + + return factory + + +@pytest.fixture(name="players") +def players_fixture( + player_factory: Callable[[int, str, str], HeosPlayer], +) -> dict[int, HeosPlayer]: + """Create two mock HeosPlayers.""" + return { + 1: player_factory(1, "Test Player", "HEOS Drive HS2"), + 2: player_factory(2, "Test Player 2", "Speaker"), + } @pytest.fixture(name="group") diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 60bc2a72e51..87cc8dd7dde 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,16 +1,26 @@ """Tests for the init module.""" +from collections.abc import Callable from typing import cast from unittest.mock import Mock -from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType +from pyheos import ( + HeosError, + HeosOptions, + HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import MockHeos @@ -255,3 +265,64 @@ async def test_remove_config_entry_device( ws_client = await hass_ws_client(hass) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] == expected_result + + +async def test_reconnected_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players after reconnecting.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + + # Simulate reconnection + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + +async def test_players_changed_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players on change event.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + + # Simulate players changed event + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, + const.EVENT_PLAYERS_CHANGED, + PlayerUpdateResult([3], [], {}), + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") From 67fcbc4c286a12a8040e77967a96b57f1386fbb5 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Tue, 18 Feb 2025 01:59:28 +1030 Subject: [PATCH 143/155] Add LV-RH131S-WM Air Purifier (#138626) * Add LV-RH131S-WM Air Purifier Fix 138486 * Update homeassistant/components/vesync/const.py --- homeassistant/components/vesync/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 897c8d2b745..2e51b96451c 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -63,6 +63,7 @@ SKU_TO_BASE_DEVICE = { # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S From 25296e1b8f98c8ef201f2af5d755d8a9bd010e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 17 Feb 2025 16:12:55 +0000 Subject: [PATCH 144/155] Move ZHA debug logs handling out of event loop (#138568) --- homeassistant/components/zha/helpers.py | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index c31627d3dc3..700e2833705 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -11,6 +11,7 @@ import enum import functools import itertools import logging +import queue import re import time from types import MappingProxyType @@ -111,9 +112,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType +from homeassistant.util.logging import HomeAssistantQueueHandler from .const import ( ATTR_ACTIVE_COORDINATOR, @@ -505,7 +507,14 @@ class ZHAGatewayProxy(EventBase): DEBUG_LEVEL_CURRENT: async_capture_log_levels(), } self.debug_enabled: bool = False - self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + + log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + log_simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + self._log_queue_handler = HomeAssistantQueueHandler(log_simple_queue) + self._log_queue_handler.listener = logging.handlers.QueueListener( + log_simple_queue, log_relay_handler + ) + self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) self._reload_task: asyncio.Task | None = None @@ -736,10 +745,13 @@ class ZHAGatewayProxy(EventBase): self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() if filterer: - self._log_relay_handler.addFilter(filterer) + self._log_queue_handler.addFilter(filterer) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).addHandler(self._log_relay_handler) + logging.getLogger(logger_name).addHandler(self._log_queue_handler) self.debug_enabled = True @@ -749,9 +761,14 @@ class ZHAGatewayProxy(EventBase): async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL]) self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).removeHandler(self._log_relay_handler) + logging.getLogger(logger_name).removeHandler(self._log_queue_handler) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.stop() + if filterer: - self._log_relay_handler.removeFilter(filterer) + self._log_queue_handler.removeFilter(filterer) + self.debug_enabled = False async def shutdown(self) -> None: @@ -978,7 +995,7 @@ class LogRelayHandler(logging.Handler): entry = LogEntry( record, self.paths_re, figure_out_source=record.levelno >= logging.WARNING ) - async_dispatcher_send( + dispatcher_send( self.hass, ZHA_GW_MSG, {ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()}, From 04b826daa12bb367d978330d1a0f3f5bc338f40e Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 18 Feb 2025 01:30:41 +0900 Subject: [PATCH 145/155] Add sensors for washer and system boiler in LG ThinQ (#137514) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 6 +++++ homeassistant/components/lg_thinq/sensor.py | 27 +++++++++++++++++++ .../components/lg_thinq/strings.json | 15 +++++++++++ 3 files changed, 48 insertions(+) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 42ae5746f24..db33106da79 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -407,6 +407,12 @@ }, "power_level_for_location": { "default": "mdi:radiator" + }, + "cycle_count": { + "default": "mdi:counter" + }, + "cycle_count_for_location": { + "default": "mdi:counter" } } } diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index bb190cccde9..95198d931a1 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -248,6 +248,24 @@ TEMPERATURE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, translation_key=ThinQProperty.CURRENT_TEMPERATURE, ), + ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE, + ), + ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE, + ), + ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE, + ), } WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.USED_TIME: SensorEntityDescription( @@ -341,6 +359,10 @@ TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { } WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ThinQProperty.CYCLE_COUNT, + translation_key=ThinQProperty.CYCLE_COUNT, + ), RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], TIMER_SENSOR_DESC[TimerProperty.TOTAL], TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], @@ -470,6 +492,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ), DeviceType.STYLER: WASHER_SENSORS, + DeviceType.SYSTEM_BOILER: ( + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE], + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index dee2d21e05a..359ac40e1f1 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -305,6 +305,15 @@ "current_temperature": { "name": "Current temperature" }, + "room_air_current_temperature": { + "name": "Indoor temperature" + }, + "room_in_water_current_temperature": { + "name": "Inlet temperature" + }, + "room_out_water_current_temperature": { + "name": "Outlet temperature" + }, "temperature": { "name": "Temperature" }, @@ -848,6 +857,12 @@ }, "power_level_for_location": { "name": "{location} power level" + }, + "cycle_count": { + "name": "Cycles" + }, + "cycle_count_for_location": { + "name": "{location} cycles" } }, "select": { From ff16e587e8aeb1afc9ebe15a738001657eaabe71 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 17 Feb 2025 17:45:26 +0100 Subject: [PATCH 146/155] Bump airgradient to 0.9.2 (#138725) * Bump airgradient to 0.9.2 * Bump airgradient to 0.9.2 --- homeassistant/components/airgradient/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airgradient/snapshots/test_diagnostics.ambr | 6 +++--- .../components/airgradient/snapshots/test_sensor.ambr | 10 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index 13764142697..afaf2698ced 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["airgradient==0.9.1"], + "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9153674fdcc..9efa46334a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 164562a485b..0ced3ce92ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -413,7 +413,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr index a96dfb95382..624a6f76f8d 100644 --- a/tests/components/airgradient/snapshots/test_diagnostics.ambr +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -25,13 +25,13 @@ 'nitrogen_index': 1, 'pm003_count': 270, 'pm01': 22, - 'pm02': 34, + 'pm02': 34.0, 'pm10': 41, 'raw_ambient_temperature': 27.96, - 'raw_nitrogen': 16931, + 'raw_nitrogen': 16931.0, 'raw_pm02': 34, 'raw_relative_humidity': 48.0, - 'raw_total_volatile_organic_component': 31792, + 'raw_total_volatile_organic_component': 31792.0, 'rco2': 778, 'relative_humidity': 47.0, 'serial_number': '84fce612f5b8', diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 38a6774b6db..374d9a60e4e 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -724,7 +724,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '34', + 'state': '34.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_nox-entry] @@ -775,7 +775,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16931', + 'state': '16931.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] @@ -878,7 +878,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '31792', + 'state': '31792.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_signal_strength-entry] @@ -1280,7 +1280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16359', + 'state': '16359.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_raw_voc-entry] @@ -1331,7 +1331,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30802', + 'state': '30802.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_signal_strength-entry] From e0795e6d07fd9a29d58d3a7233fe34a742429528 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Feb 2025 18:16:57 +0100 Subject: [PATCH 147/155] Improve config entry state transitions when unloading and removing entries (#138522) * Improve config entry state transitions when unloading and removing entries * Update integrations which check for a single loaded entry * Update tests checking state after unload fails * Update homeassistant/config_entries.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/adguard/__init__.py | 9 ++------ .../google_assistant_sdk/__init__.py | 9 ++------ .../components/google_mail/__init__.py | 9 ++------ .../components/google_sheets/__init__.py | 9 ++------ homeassistant/components/guardian/__init__.py | 9 ++------ homeassistant/components/lookin/__init__.py | 9 ++------ .../components/motion_blinds/__init__.py | 9 ++------ .../components/netgear_lte/__init__.py | 8 +------ .../components/rainmachine/__init__.py | 9 ++------ .../components/simplisafe/__init__.py | 9 ++------ .../components/tplink_omada/__init__.py | 9 ++------ .../components/xiaomi_aqara/__init__.py | 9 ++------ homeassistant/config_entries.py | 23 +++++++++++++------ tests/components/matter/test_init.py | 2 +- tests/components/unifi/test_hub.py | 2 +- tests/components/zwave_js/test_init.py | 2 +- tests/test_config_entries.py | 8 +++---- 17 files changed, 46 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index f8ddeba6767..bbc763d7ec3 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Unload AdGuard Home config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # This is the last loaded instance of AdGuard, deregister any services hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 4ea496f2824..a08d7554516 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -10,7 +10,7 @@ from google.oauth2.credentials import Credentials import voluptuous as vol from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import ( HomeAssistant, @@ -99,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 7fae5f18da5..8ef978568dc 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery @@ -59,12 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Unload a config entry.""" - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index faf1ff1ee0b..afafce816a9 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -12,7 +12,7 @@ from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ( @@ -81,12 +81,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleSheetsConfigEntry ) -> bool: """Unload a config entry.""" - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index c1cbb4c0e5a..075c388c4e4 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -11,7 +11,7 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_DEVICE_ID, @@ -247,12 +247,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of Guardian, deregister any services # defined during integration setup: for service_name in SERVICES: diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 2fbabc12747..247282309e4 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -19,7 +19,7 @@ from aiolookin import ( ) from aiolookin.models import UDPCommandType, UDPEvent -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -192,12 +192,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] await manager.async_stop() return unload_ok diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index fa1664353e1..df06ffb75fc 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from motionblinds import AsyncMotionMulticast -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -124,12 +124,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) hass.data[DOMAIN].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a756d85c866..47a39a39be0 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar import eternalegypt from eternalegypt.eternalegypt import SMS -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -117,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): hass.data.pop(DOMAIN, None) for service_name in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 4d486c9c6aa..65648b8d44f 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -13,7 +13,7 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError, UnknownAPICallError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, @@ -465,12 +465,7 @@ async def async_unload_entry( ) -> bool: """Unload an RainMachine config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state is ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of RainMachine, deregister any services # defined during integration setup: for service_name in ( diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 2f19c5117a4..8a75baa69c6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -39,7 +39,7 @@ from simplipy.websocket import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_ID, @@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of SimpliSafe, deregister any services # defined during integration setup: for service_name in SERVICES: diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 06df118463b..7ea7fd95fef 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import ( UnsupportedControllerVersion, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # This is the last loaded instance of Omada, deregister any services hass.services.async_remove(DOMAIN, "reconnect_client") diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 579994aaf6b..6e4d143d84e 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway from homeassistant.components import persistent_notification -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, @@ -216,12 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No gateways left, stop Xiaomi socket unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a103148e3b1..871b476227c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -155,6 +155,8 @@ class ConfigEntryState(Enum): """An error occurred when trying to unload the entry""" SETUP_IN_PROGRESS = "setup_in_progress", False """The config entry is setting up.""" + UNLOAD_IN_PROGRESS = "unload_in_progress", False + """The config entry is being unloaded.""" _recoverable: bool @@ -955,18 +957,25 @@ class ConfigEntry[_DataT = Any]: ) return False + if domain_is_integration: + self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) try: result = await component.async_unload_entry(hass, self) assert isinstance(result, bool) - # Only adjust state if we unloaded the component - if domain_is_integration and result: - await self._async_process_on_unload(hass) - if hasattr(self, "runtime_data"): - object.__delattr__(self, "runtime_data") + # Only do side effects if we unloaded the integration + if domain_is_integration: + if result: + await self._async_process_on_unload(hass) + if hasattr(self, "runtime_data"): + object.__delattr__(self, "runtime_data") - self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + else: + self._async_set_state( + hass, ConfigEntryState.FAILED_UNLOAD, "Unload failed" + ) except Exception as exc: _LOGGER.exception( @@ -2052,9 +2061,9 @@ class ConfigEntries: else: unload_success = await self.async_unload(entry_id, _lock=False) + del self._entries[entry.entry_id] await entry.async_remove(self.hass) - del self._entries[entry.entry_id] self.async_update_issues() self._async_schedule_save() diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index f6576689413..553358f12e3 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -502,7 +502,7 @@ async def test_issue_registry_invalid_version( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 5492f6fe0df..8b129d3d648 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -76,7 +76,7 @@ async def test_reset_fails( return_value=False, ): assert not await hass.config_entries.async_unload(config_entry_setup.entry_id) - assert config_entry_setup.state is ConfigEntryState.LOADED + assert config_entry_setup.state is ConfigEntryState.FAILED_UNLOAD @pytest.mark.usefixtures("mock_device_registry") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4f858f3e545..c575066b57c 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -847,7 +847,7 @@ async def test_issue_registry( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index bf2280790fa..acc79deb538 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -468,8 +468,8 @@ async def test_remove_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> None: """Mock removing an entry.""" - # Check that the entry is not yet removed from config entries - assert hass.config_entries.async_get_entry(entry.entry_id) + # Check that the entry is no longer in the config entries + assert not hass.config_entries.async_get_entry(entry.entry_id) remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") @@ -2623,7 +2623,7 @@ async def test_entry_setup_invalid_state( ("unload_result", "expected_result", "expected_state", "has_runtime_data"), [ (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), - (False, False, config_entries.ConfigEntryState.LOADED, True), + (False, False, config_entries.ConfigEntryState.FAILED_UNLOAD, True), ], ) async def test_entry_unload( @@ -2648,7 +2648,7 @@ async def test_entry_unload( """Mock unload entry.""" unload_entry_calls.append(None) verify_runtime_data() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is config_entries.ConfigEntryState.UNLOAD_IN_PROGRESS return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) From d7e796e9f9c7c44986b378c5544448697a192db7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 18:37:46 +0100 Subject: [PATCH 148/155] Fix typos in qBittorrent exceptions strings (#138728) --- homeassistant/components/qbittorrent/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 83d93766ee4..eb7cd19faca 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -109,16 +109,16 @@ }, "exceptions": { "invalid_device": { - "message": "No device with id {device_id} was found" + "message": "No device with ID {device_id} was found" }, "invalid_entry_id": { - "message": "No entry with id {device_id} was found" + "message": "No entry with ID {device_id} was found" }, "login_error": { - "message": "A login error occured. Please check you username and password." + "message": "A login error occured. Please check your username and password." }, "cannot_connect": { - "message": "Can't connect to QBittorrent, please check your configuration." + "message": "Can't connect to qBittorrent, please check your configuration." } } } From da9fbf21dffc93c1392f85007f67d5294826399f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:04:39 -0600 Subject: [PATCH 149/155] Update HEOS repair issues quality scale item (#138724) --- homeassistant/components/heos/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index a08e2dca544..6ade4e6ffb9 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -57,7 +57,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: done stale-devices: done # Platinum async-dependency: done From 3b6e3fe457a7f206a562dea43fdfb74ba9bca541 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:10:56 +0100 Subject: [PATCH 150/155] Fix race condition on eheimdigital coordinator setup (#138580) --- .../components/eheimdigital/coordinator.py | 19 +++++++-- tests/components/eheimdigital/conftest.py | 13 ++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++------- tests/components/eheimdigital/test_init.py | 5 ++- tests/components/eheimdigital/test_light.py | 42 ++++++++++--------- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index 4359a314494..6e96fb388ee 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,16 +2,18 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -43,12 +45,14 @@ class EheimDigitalUpdateCoordinator( name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) + self.main_device_added_event = asyncio.Event() self.hub = EheimDigitalHub( host=self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass), loop=hass.loop, receive_callback=self._async_receive_callback, device_found_callback=self._async_device_found, + main_device_added_event=self.main_device_added_event, ) self.known_devices: set[str] = set() self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() @@ -76,8 +80,17 @@ class EheimDigitalUpdateCoordinator( self.async_set_updated_data(self.hub.devices) async def _async_setup(self) -> None: - await self.hub.connect() - await self.hub.update() + try: + await self.hub.connect() + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + await self.hub.update() + except (TimeoutError, EheimDigitalClientError) as err: + raise ConfigEntryNotReady from err async def _async_update_data(self) -> dict[str, EheimDigitalDevice]: try: diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index afb97b97569..ae1bc74df90 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.eheimdigital.const import DOMAIN from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -79,3 +80,15 @@ def eheimdigital_hub_mock( } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Initialize the integration.""" + + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", new=AsyncMock + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f1f29ce9d34..4abc33e449e 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -1,6 +1,6 @@ """Tests for the climate module.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.types import ( EheimDeviceType, @@ -31,6 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from .conftest import init_integration + from tests.common import MockConfigEntry, snapshot_platform @@ -45,7 +47,13 @@ async def test_setup_heater( """Test climate platform setup for heater.""" mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( @@ -69,7 +77,13 @@ async def test_dynamic_new_devices( eheimdigital_hub_mock.return_value.devices = {} - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) assert ( @@ -108,9 +122,7 @@ async def test_set_preset_mode( heater_mode: HeaterMode, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -146,9 +158,7 @@ async def test_set_temperature( mock_config_entry: MockConfigEntry, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -189,9 +199,7 @@ async def test_set_hvac_mode( active: bool, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -231,9 +239,8 @@ async def test_state_update( heater_mock.is_heating = False heater_mock.operation_mode = HeaterMode.BIO - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER ) diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index 211a8b3b6fd..c64997ee372 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import init_integration + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -21,9 +23,8 @@ async def test_remove_device( ) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index da224979c43..81b63218085 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -1,7 +1,7 @@ """Tests for the light module.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from eheimdigital.types import EheimDeviceType, LightMode @@ -26,6 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util.color import value_to_brightness +from .conftest import init_integration + from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -51,7 +53,13 @@ async def test_setup_classic_led_ctrl( classic_led_ctrl_mock.tankconfig = tankconfig - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( @@ -75,7 +83,13 @@ async def test_dynamic_new_devices( eheimdigital_hub_mock.return_value.devices = {} - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) assert ( @@ -106,10 +120,8 @@ async def test_turn_off( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning off the light.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await mock_config_entry.runtime_data._async_device_found( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -143,10 +155,8 @@ async def test_turn_on_brightness( expected_dim_value: int, ) -> None: """Test turning on the light with different brightness values.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -173,12 +183,10 @@ async def test_turn_on_effect( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning on the light with an effect value.""" - mock_config_entry.add_to_hass(hass) - classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -204,10 +212,8 @@ async def test_state_update( classic_led_ctrl_mock: MagicMock, ) -> None: """Test the light state update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -228,10 +234,8 @@ async def test_update_failed( freezer: FrozenDateTimeFactory, ) -> None: """Test an failed update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) From 9ac60f1c7f5061a761b4f7895cc7504f6f6ad80d Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:37:33 +0100 Subject: [PATCH 151/155] Fix small typo in qbittorrent strings.json (#138734) --- homeassistant/components/qbittorrent/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index eb7cd19faca..0dcb9298f1f 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,7 +53,7 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Conencted", + "connected": "Connected", "firewalled": "Firewalled", "disconnected": "Disconnected" } From 772e7147bd9dfab5f4c42707eabab4ff4db95b07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 20:51:30 +0100 Subject: [PATCH 152/155] Fix user-facing strings of the NWS integration (#138727) - fix sentence-casing of "API key" to match common string - remove excessive trailing period from action name - reword action description to match HA style - make "Forecast type" description UI-friendly (a selector is available) --- homeassistant/components/nws/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json index c9ee8349631..72b6a2c86b6 100644 --- a/homeassistant/components/nws/strings.json +++ b/homeassistant/components/nws/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.", + "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.", "title": "Connect to the National Weather Service", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -30,12 +30,12 @@ }, "services": { "get_forecasts_extra": { - "name": "Get extra forecasts data.", - "description": "Get extra data for weather forecasts.", + "name": "Get extra forecasts data", + "description": "Retrieves extra data for weather forecasts.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: hourly or twice_daily." + "description": "The scope of the weather forecast." } } } From bbfb9fbdaeb3c9bcb023e1c01ddfb722023d00f1 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 21:10:18 +0100 Subject: [PATCH 153/155] Mark reauthentication-flow as exempt for flexit_bacnet (#138740) --- homeassistant/components/flexit_bacnet/quality_scale.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index f59435bad0d..9a4a4eace40 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -48,7 +48,10 @@ rules: comment: | Done implicitly with coordinator. parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: + status: exempt + comment: | + Integration doesn't require any form of authentication. test-coverage: todo # Gold entity-translations: done From f9047d022342222733858a76d906915a9e530792 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 21:15:37 +0100 Subject: [PATCH 154/155] Mark action-exceptions as exempt for flexit_bacnet (#138739) * Mark action-exceptions as exempt for flexit_bacnet * Update homeassistant/components/flexit_bacnet/quality_scale.yaml --------- Co-authored-by: Josef Zweck --- homeassistant/components/flexit_bacnet/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 9a4a4eace40..eb649656c9d 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -31,7 +31,7 @@ rules: Done implicitly with `await coordinator.async_config_entry_first_refresh()`. unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt From 5658f9ca405eadc96be42a76fc4896b5d6c08ade Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 22:28:45 +0100 Subject: [PATCH 155/155] Fix wrong description of teslemetry.set_scheduled_charging action (#138723) The action allows the user to set a time at which to start charging, but the action's description uses the wrong word "completed". --- homeassistant/components/teslemetry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 68ad12a46b6..b6b3d17e37c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -712,7 +712,7 @@ "name": "Navigate to coordinates" }, "set_scheduled_charging": { - "description": "Sets a time at which charging should be completed.", + "description": "Sets a time at which charging should be started.", "fields": { "device_id": { "description": "Vehicle to schedule.",